spec

MPP.sol Session Program

Status: Draft v0.1 Last updated: 2026-05-05 Editors: psyto

This document specifies the on-chain session program for MPP.sol — the PDA layout, authorization model, instruction set, and lifecycle of a session: a pre-authorized capacity for a server to debit a token account in many small, off-chain-signed increments and settle the total in a single on-chain transaction.

It complements wire.md, which defines the off-chain debit message that this program verifies and settles.


1. Why sessions

A server that prices each request at fractions of a cent cannot afford a Solana transaction per request — even at $0.00025 per tx, the per-request overhead dominates. Sessions invert the cost model:

The result: per-request marginal cost approaches zero on-chain, and settlement frequency is tunable per server.

2. Account model

2.1 Session PDA

PDA seeds: [b"session", owner, server, session_id]
PDA owner: mppsol_session program
Field Type Description
discriminator [u8; 8] Account type tag.
owner Pubkey Wallet that opened and funded the session. Sole authority for Revoke and Close.
authorized_signer Pubkey Ed25519 key whose signatures on debit messages the program accepts. MAY equal owner. Typically a hot key held by an agent.
server Pubkey The MPP server’s pubkey. Only this signer can submit Settle.
mint Pubkey SPL token mint (e.g. USDC).
escrow Pubkey Associated token account (PDA-owned) holding the escrowed cap.
total_cap u64 Total amount escrowed at open time, atomic units.
remaining_cap u64 Decremented on each successful Settle. Settlement that would underflow this is rejected.
last_seen_sequence u64 Highest debit sequence consumed. Settlement with sequence ≤ last_seen_sequence is rejected.
expiry i64 Unix seconds. After this, only Revoke and Close are accepted.
state u8 0=Active, 1=Revoked, 2=Closed.
cluster_genesis_hash [u8; 32] First 32 bytes of the cluster’s genesis hash. Anti-replay across clusters.
session_id [u8; 16] Random, owner-chosen. Allows multiple sessions between same owner+server.
bump u8 PDA bump seed.

Total: 8 + 32×5 + 8×3 + 8 + 1 + 32 + 16 + 1 = 234 bytes (+ rent).

2.2 Escrow account

A PDA-owned associated token account derived from the session PDA and mint. It holds total_cap tokens at open time and is drained by Settle. On Revoke or Close the residual returns to the owner’s token account.

3. Authorization model

Three distinct keys per session:

Key Powers
owner Open, Topup, Revoke, Close. Cannot sign debits unless also authorized_signer.
authorized_signer Signs off-chain debit messages. No on-chain authority.
server Settle. Cannot mutate caps, expiry, or state otherwise.

Splitting owner from authorized_signer lets a user keep cold-key custody of funds while delegating debit-signing to an agent’s hot key — the worst case from a leaked hot key is loss of remaining_cap to a single colluding server, not full wallet compromise.

4. Instructions

4.1 Open

Opens a new session. Signed by owner (and pays rent).

Args:

struct OpenArgs {
  authorized_signer: Pubkey,
  server: Pubkey,
  total_cap: u64,
  expiry: i64,
  session_id: [u8; 16],
}

Effects:

Reverts if: expiry ≤ now; total_cap == 0; PDA already exists; insufficient balance.

4.2 Settle

Submits one or more debit messages for on-chain settlement. Signed by server.

Args:

struct SettleArgs {
  debits: Vec<Debit>,        // 1..=MAX_BATCH (e.g. 32)
  signatures: Vec<[u8; 64]>, // same length as debits
}

struct Debit {                 // identical to wire.md §4.2
  session:    [u8; 32],
  nonce:      [u8; 32],
  amount:     u64,
  expiry:     i64,
  sequence:   u64,
  domain_sep: [u8; 16],        // ASCII "MPP.SOL/DEBIT001"
}

Verification (per debit, ordered ascending by sequence):

  1. debit.session matches the session PDA pubkey.
  2. debit.domain_sep == "MPP.SOL/DEBIT001".
  3. Ed25519 verify signatures[i] over canonical bytes of debit using authorized_signer. Performed via the Ed25519 precompile in a companion instruction within the same transaction (see §6).
  4. debit.sequence > last_seen_sequence (after applying earlier debits in this batch).
  5. debit.expiry ≥ clock.unix_timestamp.
  6. debit.amount > 0 and cumulative_amount + debit.amount ≤ remaining_cap.

Effects (after all debits verified):

Reverts if: state ≠ Active; any debit fails verification; escrow has insufficient balance (should never happen given cap accounting, but checked defensively).

debit.nonce is not verified on-chain — it is bound off-chain to the server’s HTTP challenge (per wire.md §4.2) and is included in the signed message only to make the receipt-as-debit anchor portable to auditors.

4.3 Topup

Increases total_cap by depositing additional tokens into escrow. Signed by owner.

Args: { amount: u64 }

Effects: Transfer amount from owner ATA to escrow; total_cap += amount; remaining_cap += amount.

Reverts if: state ≠ Active; expiry ≤ now.

4.4 Revoke

Marks the session unusable for new debits. Settlements for already-issued debits with expiry > now MAY still succeed; once expired they will fail.

Either owner or server may revoke. (Allowing the server to revoke lets it cleanly end a session it no longer trusts — e.g. signature anomalies — without leaving funds locked indefinitely.)

Effects: state = Revoked. Escrow is not drained yet — pending debits may still settle until they expire.

4.5 Close

Closes a Revoked session past the maximum debit expiry, or an Active session past expiry. Returns residual escrow + rent to owner. Signed by owner.

Effects: Transfer all remaining escrow to owner ATA; close escrow ATA; close session PDA (refunding rent).

Reverts if: state == Active and now < expiry; state == Revoked and now < expiry + REVOKE_GRACE_PERIOD (e.g. 24h, configurable per program version).

5. Lifecycle

        ┌───────┐  Open   ┌────────┐     Revoke      ┌─────────┐  Close  ┌────────┐
  none ─┤ Open  ├────────▶│ Active ├────────────────▶│ Revoked ├────────▶│ Closed │
        └───────┘         └───┬────┘                 └────┬────┘         └────────┘
                              │ expiry passed             │ + grace period
                              ▼                           ▼
                          ┌────────┐                   close
                          │Expired │ (state stays Active; only Close accepted)
                          └────────┘

Topup and Settle are valid only in Active. Settle is also valid in Revoked until the youngest pending debit expires — this is what makes graceful server-initiated revocation safe.

6. Ed25519 verification pattern

Solana programs verify Ed25519 signatures via the Ed25519SigVerify111111111111111111111111111 native program, called as a companion instruction in the same transaction.

A Settle transaction therefore looks like:

ix[0] = Ed25519 precompile  (verifies all signatures+messages+pubkeys)
ix[1] = mppsol_session::Settle(args)

The Settle handler reads Sysvar: Instructions to confirm ix[0] is the Ed25519 precompile and that its serialized (pubkey, message, signature) tuples exactly match the (authorized_signer, debit_bytes, signature) tuples passed to Settle. This pattern is the standard Solana idiom for off-chain-signed message verification (used by Squads, Light Protocol, and others).

A batch of N debits adds ~5,500 + N × 1,800 CU for the precompile; combined with the program’s own logic a 32-debit batch fits comfortably under the 1.4M CU per-tx limit.

7. Settlement batching strategy (informative)

Servers SHOULD batch debits to amortize on-chain cost. Suggested triggers:

Servers MUST settle before the youngest pending debit expires, or the debit becomes unredeemable. Servers SHOULD reserve a safety margin (e.g. 30s) before debit expiry.

8. Failure modes and recovery

8.1 Server holds debits but crashes

Off-chain debit state is the server’s authoritative ledger of unsettled revenue. Servers MUST persist accepted debits durably before returning the resource. On restart, replay unsettled debits.

8.2 Owner’s authorized signer is leaked

Worst case: attacker drains remaining_cap through colluding servers. Mitigation: short expiries, small caps, per-server sessions (so one leaked agent does not authorize debits for unrelated services).

8.3 Server signs colluding debits with a malicious recipient

Cannot happen: the recipient on Settle is fixed to the server’s token account, which was committed at session Open. A different server cannot redeem these debits.

8.4 Cluster confusion

The session records cluster_genesis_hash. Any third-party verifying a receipt can confirm the cluster matches the receipt’s claimed cluster.

9. Anchor IDL sketch (informative)

#[program]
pub mod mppsol_session {
    use super::*;

    pub fn open(ctx: Context<Open>, args: OpenArgs) -> Result<()> {  }
    pub fn settle(ctx: Context<Settle>, args: SettleArgs) -> Result<()> {  }
    pub fn topup(ctx: Context<Topup>, amount: u64) -> Result<()> {  }
    pub fn revoke(ctx: Context<Revoke>) -> Result<()> {  }
    pub fn close(ctx: Context<Close>) -> Result<()> {  }
}

#[account]
pub struct Session {
    pub owner: Pubkey,
    pub authorized_signer: Pubkey,
    pub server: Pubkey,
    pub mint: Pubkey,
    pub escrow: Pubkey,
    pub total_cap: u64,
    pub remaining_cap: u64,
    pub last_seen_sequence: u64,
    pub expiry: i64,
    pub state: u8,
    pub cluster_genesis_hash: [u8; 32],
    pub session_id: [u8; 16],
    pub bump: u8,
}

10. Open questions

11. References