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.
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.
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).
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.
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.
OpenOpens 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:
total_cap of mint from the owner’s source token account to escrow.cluster_genesis_hash = first_32_bytes(genesis_hash) (read from the
Sysvar1nstructions1111… or via a passed-in hash verified against Slothashes).state = Active, remaining_cap = total_cap, last_seen_sequence = 0.Reverts if: expiry ≤ now; total_cap == 0; PDA already exists; insufficient balance.
SettleSubmits 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):
debit.session matches the session PDA pubkey.debit.domain_sep == "MPP.SOL/DEBIT001".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).debit.sequence > last_seen_sequence (after applying earlier debits in
this batch).debit.expiry ≥ clock.unix_timestamp.debit.amount > 0 and cumulative_amount + debit.amount ≤ remaining_cap.Effects (after all debits verified):
cumulative_amount from escrow to server’s token account
(passed as instruction account).remaining_cap -= cumulative_amount.last_seen_sequence = max(debits[*].sequence).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.
TopupIncreases 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.
RevokeMarks 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.
CloseCloses 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).
┌───────┐ 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.
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.
Servers SHOULD batch debits to amortize on-chain cost. Suggested triggers:
remaining_cap.expiry passes.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.
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.
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).
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.
The session records cluster_genesis_hash. Any third-party verifying a
receipt can confirm the cluster matches the receipt’s claimed cluster.
#[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,
}
authorized_signer be allowed to delegate
signing to a sub-key with a smaller cap, off-chain only? Defer to v0.2.mint is Token-2022 with a transfer
fee extension, the recipient receives less than debit.amount. Spec
decision: amount is debited from escrow; the server bears the fee.wire.md — header format and off-chain debit message.