Skip to content

Turbin3/accel-StealthX

Repository files navigation

stealthX

Architecture Document — Private MPP

toupload

View Full PDF of architecture diaghrm

Private Machine Payment Protocol — On-chain & system architecture

Project Private MPP (Private Machine Payment Protocol)
Source PRD README.md (v1.0, 2026-06-01)
Document version 1.1
Date 2026-06-11
Scope Solana on-chain program (Rust + Pinocchio), file layout, account model, instruction set, function names, PER CPI integration, and the surrounding off-chain components.
Audience Smart-contract engineers, integrators, and security reviewers.

1. Architecture at a Glance

Private MPP has one on-chain program and four off-chain services. This document specifies the on-chain program in full (its crate, files, accounts, instructions, and functions) and the off-chain components at the interface level only.

┌───────────────────────────────────────────────────────────────────────┐
│ OFF-CHAIN                                                               │
│   private-mpp-agent-sdk     private-mpp-merchant-sdk                     │
│   private-mpp-facilitator   private-mpp-per (Private Payment API client) │
└───────────────────────────────────────────────────────────────────────┘
                    │ builds / signs / submits transactions
                    ▼
┌───────────────────────────────────────────────────────────────────────┐
│ ON-CHAIN  ── private_mpp program (Rust + Pinocchio)                      │
│   Instructions ─► Handlers ─► State PDAs ─► PER CPI (delegate/commit)    │
└───────────────────────────────────────────────────────────────────────┘
                    │ CPI
                    ▼
   Delegation Program  ·  SPL Token Program  ·  System Program
   DELeGGvXpWV2fqJUhqcF5ZSYMS4JTLjteaAMARRSaeSh

The program is responsible for the five PRD mechanisms (§6.2): private settlement anchoring, gasless-compatible settlement records, compliant account-scoped disclosure, x402 binding (via settlement nonces), and on-chain commit/anchoring.


2. On-chain Program — Repository Layout

Single Cargo workspace, organized under three top-level folders: contract/ (the on-chain program + its test harness), services/ (runtime services such as the x402 facilitator), and sdk/ (off-chain client libraries that consume the program, split by language). The on-chain crate is private-mpp-program.

stealthX/
├── Cargo.toml                      # workspace manifest (members: contract/programs/*)
├── Cargo.lock
├── README.md
├── .gitignore
│
├── contract/                       # ── on-chain program + its tests ──
│   ├── programs/
│   │   └── private-mpp-program/
│   │       ├── Cargo.toml          # pinocchio, pinocchio-token, pinocchio-system, pinocchio-log
│   │       ├── Xargo.toml
│   │       └── src/
│   │           ├── lib.rs          # entrypoint, program ID, module wiring
│   │           ├── entrypoint.rs   # process_instruction dispatcher
│   │           ├── instruction.rs  # MppInstruction enum + (de)serialization
│   │           ├── error.rs        # MppError enum + ProgramError mapping
│   │           ├── constants.rs    # seeds, sizes, program IDs, discriminators
│   │           │
│   │           ├── state/
│   │           │   ├── mod.rs
│   │           │   ├── merchant.rs     # Merchant account
│   │           │   ├── settlement.rs   # Settlement record account
│   │           │   ├── access_grant.rs # AccessGrant (scoped reader) account
│   │           │   └── nonce.rs        # PaymentNonce (replay guard) account
│   │           │
│   │           ├── instructions/
│   │           │   ├── mod.rs
│   │           │   ├── init_merchant.rs
│   │           │   ├── delegate_settlement.rs
│   │           │   ├── record_payment.rs
│   │           │   ├── commit_settlement.rs
│   │           │   ├── undelegate.rs
│   │           │   └── grant_access.rs
│   │           │
│   │           ├── per_cpi/         # hand-written MagicBlock CPIs (see §8)
│   │           │   ├── mod.rs
│   │           │   ├── delegate.rs      # delegate instruction builder
│   │           │   ├── commit.rs        # commit_and_undelegate builders
│   │           │   ├── undelegate.rs    # undelegation callback handler
│   │           │   └── seeds.rs         # delegation buffer/record PDA derivation
│   │           │
│   │           └── utils/
│   │               ├── mod.rs
│   │               ├── account.rs   # create_pda, assert_owner, realloc helpers
│   │               ├── pda.rs       # find/derive PDA seed helpers
│   │               └── validation.rs# signer / mint / amount checks
│   │
│   └── tests/
│       ├── litesvm/                # instruction + lifecycle tests (LiteSVM), with CU tracking
│       ├── mock-delegation-program/# stub delegation program for delegate_settlement tests
│       └── devnet/                 # end-to-end devnet scripts
│           ├── exercise.mjs        # full contract lifecycle (init→…→undelegate) — §15
│           └── per.js              # standalone PER private-payment proof (`node per`) — §17
│
├── per.js                          # repo-root launcher → contract/tests/devnet/per.js
├── merchant-wallet.json            # reusable devnet merchant keypair (gitignored)
│
├── services/                       # ── runtime services (x402 facilitator, etc.) ──
│   └── facilitator/                # Private MPP x402 facilitator (verify/settle) — deferred
│
└── sdk/                            # ── off-chain consumers (was crates/ + packages/) ──
    ├── rust/
    │   ├── private-mpp-per/        # Rust client for the Private Payment API
    │   └── private-mpp-sdk/        # Rust core SDK (agent + merchant)
    │
    └── ts/                         # TypeScript bindings (thin)
        ├── per/                    # @private-mpp/per
        ├── svm-exact-private/      # @private-mpp/svm/exact-private (client+server)
        ├── agent-sdk/              # @private-mpp/agent
        └── merchant-sdk/           # @private-mpp/merchant

Pinocchio version note. Target pinocchio 0.11.1 (latest stable). Account type is AccountView (not AccountInfo), address type is Address (from solana-address), data access is via try_borrow/try_borrow_mut (the latter requires &mut AccountView), and the entrypoint handler receives program_id: &Address and accounts: &mut [AccountView]. Pinocchio 0.11 is built on the split solana-*-view 2.x crates (solana-account-view, solana-instruction-view, solana-address, solana-program-error); companion crates must share that foundation. See §3.1 for the verified-compatible dependency set. Generate APIs against the actual installed version, not from memory.


3. Dependencies & Program ID

3.1 Verified-compatible dependency set

Versions below were resolved against crates.io (2026-06-05) and checked for a single, consistent dependency tree. The unifying constraint is the solana-*-view 2.x foundation that pinocchio 0.11 exposes — every companion crate either depends on pinocchio ^0.11 or builds on the same solana-account-view 2.x / solana-instruction-view 2.x / solana-address 2.x crates, so no second pinocchio is pulled.

Crate Version Why this version Compatibility check
pinocchio 0.11.1 latest stable; the runtime foundation.
pinocchio-token 0.6.0 SPL Token CPIs (record_payment). Built on solana-account-view ^2.0 / solana-instruction-view ^2.1 — same as pinocchio 0.11. ✓
pinocchio-system 0.6.1 System-program CPIs (PDA creation). Declares pinocchio ^0.11. ✓
pinocchio-log 0.5.1 low-CU logging. Foundation-agnostic. ✓
magicblock-delegation-program-api 3.0.0 source of truth for delegation discriminators, instruction layouts, and buffer/record/metadata PDA seeds used by per_cpi/. Reference/validation crate (see note).
magicblock-magic-program-api 0.12.0 magic-program ID + commit discriminators for commit_settlement. Reference/validation crate.
ephemeral-rollups-sdk 0.15.3 the Anchor-first SDK we are replacing; kept as a dev-dependency to cross-check our hand-built CPI bytes. dev-dependency only.
litesvm 0.12.0 test SVM harness (§13) — bundles the SPL Token program for record_payment. Matches the solana-account-view 2.x baseline. dev-dependency only.

⚠️ Do not depend on pinocchio-pubkey. Its latest version (0.3.0) still pins pinocchio ^0.9 (i.e. >=0.9, <0.10), which would resolve a second pinocchio (0.9.x) into the tree alongside 0.11 and break type compatibility for Address/AccountView. No replacement crate is needed: Address::from_str_const("…") (from solana-address 2.x, re-exported at the crate root as pinocchio::Address — there is no pinocchio::pubkey module in 0.11) decodes base58 program IDs at compile time. Revisit if/when a pinocchio-pubkey release bumps to pinocchio ^0.11.

MagicBlock API crates as reference, not runtime weight. magicblock-delegation-program-api / -magic-program-api depend on solana-program 3.x; magicblock-delegation-program-api additionally pulls an older pinocchio 0.10 (and pinocchio-pubkey 0.3pinocchio 0.9) transitively. All of this is confined to the dev-dependency tree — none of it reaches the on-chain binary (verified: cargo tree -d -e normal is empty). To keep the program minimal (PRD §7.3), prefer copying the relevant discriminators and PDA seeds into per_cpi/seeds.rs as consts and pinning them with golden-vector tests (§13) against these crates, rather than linking them into the on-chain binary. List them under [dev-dependencies].

3.2 contract/programs/private-mpp-program/Cargo.toml

[package]
name = "private-mpp-program"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "lib"]

[features]
no-entrypoint = []

[dependencies]
pinocchio        = "0.11.1"
pinocchio-token  = "0.6.0"
pinocchio-system = "0.6.1"
pinocchio-log    = "0.5.1"
# No pinocchio-pubkey: program IDs use Address::from_str_const (see §3.1).

[dev-dependencies]
# Reference sources for delegation/commit discriminators & PDA seeds — golden-vector tests only (§3.1 / §13).
# These crates depend on solana-program 3.x (not pinocchio) and must NOT be promoted to [dependencies].
magicblock-delegation-program-api = "3.0.0"
magicblock-magic-program-api      = "0.12.0"
ephemeral-rollups-sdk             = "0.15.3"
litesvm                           = "0.12.0"  # pinned — matches solana-account-view 2.x used by pinocchio 0.11

3.3 Program ID & global constants

src/constants.rs:

use pinocchio::Address; // re-export of solana_address::Address (a newtype over [u8; 32])

// Program ID — the deployed keypair's pubkey (program-keypair.json in the crate).
// Address::from_str_const decodes base58 at compile time — no pinocchio-pubkey dep.
pub const ID: Address = Address::from_str_const("AGVBmw3SWNBoaNG9yCJtT5H2NBaWfQdqAA8gzw9BMWrE");

// External programs
pub const DELEGATION_PROGRAM_ID: Address =
    Address::from_str_const("DELeGGvXpWV2fqJUhqcF5ZSYMS4JTLjteaAMARRSaeSh");
pub const SPL_TOKEN_PROGRAM_ID: Address =
    Address::from_str_const("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
pub const SYSTEM_PROGRAM_ID: Address = Address::new_from_array([0u8; 32]);

// MagicBlock Magic Program + fixed Magic Context (used by commit_settlement to
// schedule the ER commit-and-undelegate intent bundle). The context is a fixed
// address, not a PDA.
pub const MAGIC_PROGRAM_ID: Address =
    Address::from_str_const("Magic11111111111111111111111111111111111111");
pub const MAGIC_CONTEXT_PUBKEY: Address =
    Address::from_str_const("MagicContext1111111111111111111111111111111");

// PDA seeds
pub const MERCHANT_SEED:    &[u8] = b"merchant";
pub const SETTLEMENT_SEED:  &[u8] = b"settlement";
pub const ACCESS_SEED:      &[u8] = b"access";
pub const NONCE_SEED:       &[u8] = b"nonce";

// Account sizes (bytes, fixed layouts — see §5)
pub const MERCHANT_LEN:    usize = 1 + 32 + 32 + 32 + 8 + 1 + 1;       // 107
pub const SETTLEMENT_LEN:  usize = 1 + 32 + 32 + 32 + 8 + 32 + 1 + 8;  // 146
pub const ACCESS_GRANT_LEN:usize = 1 + 32 + 32 + 1 + 8;               // 74
pub const NONCE_LEN:       usize = 1 + 32 + 1;                        // 34

// Roles for AccessGrant
pub const ROLE_MERCHANT: u8 = 0;
pub const ROLE_AUDITOR:  u8 = 1;

4. Program Derived Addresses (PDA Model)

All program-owned state is in PDAs so the program retains delegation authority and can re-derive deterministically.

Account Seeds Owner Notes
Merchant ["merchant", merchant_authority] program One per merchant payout authority.
Settlement ["settlement", merchant_pda, payment_nonce] program → Delegation Program (while delegated) → program The PDA delegated to the TEE ER; holds the private settlement record.
AccessGrant ["access", settlement_pda, reader_pubkey] program Scoped read authorization (merchant verifier or auditor).
PaymentNonce ["nonce", payment_nonce] program Replay guard (FR12); created once, presence = consumed.

payment_nonce is the 32-byte settlement reference (perRef) that ties the on-chain record to the x402 PAYMENT-SIGNATURE payload (PRD §A.2). The bump for each PDA is stored in its account (canonical bump) to avoid recomputation on every instruction.

Bumps travel in the instruction data. Pinocchio 0.11 ships no on-chain find_program_address, so the program never searches for a canonical bump. Clients derive each canonical bump off-chain and pass it in the instruction payload (e.g. settlement_bump, nonce_bump, buffer_bump on DelegateSettlement); the System / Delegation CPIs validate them by failing if the signer seeds do not derive to the expected address. The stored bump (above) is then the cached copy for later instructions.


5. Account State Layouts

src/state/ — each struct is a fixed-size, zero-copy view over the account's data (bytemuck-style manual (de)serialization to stay zero-dependency; first byte is a discriminator/is_initialized tag).

5.1 state/merchant.rsMerchant

pub struct Merchant {
    pub is_initialized: u8,        // 1
    pub authority:      [u8; 32],  // payout authority (signer for admin ops)
    pub payout_owner:   [u8; 32],  // destination token-account owner (payTo)
    pub mint:           [u8; 32],  // accepted SPL mint (e.g. USDC)
    pub settlements:    u64,       // monotonic counter (stats / nonce salt)
    pub access_policy:  u8,        // bitflags: auditor-allowed, fallback-allowed
    pub bump:           u8,        // canonical PDA bump
}

5.2 state/settlement.rsSettlement

The account whose ownership is delegated to the ER for the private transfer; after commit it holds the durable, confidential settlement record (PRD §7.4 record_payment / commit_settlement).

pub struct Settlement {
    pub is_initialized: u8,        // 1
    pub merchant:       [u8; 32],  // Merchant PDA
    pub payer:          [u8; 32],  // agent (confidential under PER access rules)
    pub mint:           [u8; 32],
    pub amount:         u64,       // atomic units
    pub payment_nonce:  [u8; 32],  // perRef — x402 binding
    pub status:         u8,        // SettlementStatus (see below)
    pub committed_slot: u64,       // slot at commit; 0 until committed
}

SettlementStatus: 0 = Pending, 1 = Delegated, 2 = Recorded, 3 = Committed, 4 = Undelegated.

5.3 state/access_grant.rsAccessGrant

pub struct AccessGrant {
    pub is_initialized: u8,        // 1
    pub settlement:     [u8; 32],  // Settlement PDA this grant scopes
    pub reader:         [u8; 32],  // facilitator / auditor pubkey
    pub role:           u8,        // ROLE_MERCHANT | ROLE_AUDITOR
    pub expires_at:     i64,       // unix ts; 0 = no expiry (8 bytes)
}

5.4 state/nonce.rsPaymentNonce

pub struct PaymentNonce {
    pub is_initialized: u8,        // 1
    pub nonce:          [u8; 32],  // perRef
    pub bump:           u8,
}

Existence of an initialized PaymentNonce PDA = that payment reference has been settled. record_payment / commit_settlement create it atomically with the settlement to make replays fail (FR12).


6. Instruction Set

src/instruction.rs defines the wire format. First byte = discriminator; remainder = Borsh-free manual layout.

#[repr(u8)]
pub enum MppInstruction {
    InitMerchant       = 0,  // (payout_owner, access_policy, bump)                         — 34 B
    DelegateSettlement = 1,  // (payment_nonce, payer, amount, commit_frequency_ms,
                             //  validator_present, validator, settlement_bump,
                             //  nonce_bump, buffer_bump)                                   — 112 B
    RecordPayment      = 2,  // (payment_nonce, settlement_bump)  — runs inside the ER      — 33 B
    CommitSettlement   = 3,  // (payment_nonce, settlement_bump)  — ER commit+undelegate    — 33 B
    Undelegate         = 4,  // not tag-dispatched — see §7 (8-byte callback disc.)
    GrantAccess        = 5,  // (reader, role, expires_at, bump)                            — 42 B
}

All byte counts are the payload after the 1-byte discriminator. mint and authority for InitMerchant come from accounts, not the data. The trailing bump fields are the client-derived canonical bumps described in §4.

6.1 Instruction → handler → accounts

Discriminant Instruction Handler fn (instructions/<file>.rs) Key accounts (signer*, writable‡) PRD ref
0 InitMerchant process_init_merchant authority*, merchant_pda‡, mint, system_program §7.4 init_merchant
1 DelegateSettlement process_delegate_settlement authority*, merchant_pda, settlement_pda‡, nonce_pda‡, delegation_program, system_program §7.4 delegate_settlement
2 RecordPayment process_record_payment settlement_pda‡, merchant_pda §7.4 record_payment
3 CommitSettlement process_commit_settlement payer*‡, settlement_pda‡, magic_context‡, magic_program §7.4 commit_settlement
4 Undelegate process_undelegate settlement_pda‡ (delegated), buffer* (delegation-owned), payer*‡, system_program §7.4 undelegate
5 GrantAccess process_grant_access authority*, merchant_pda, settlement_pda, access_grant_pda‡, system_program §7.4 grant_access

6.2 Handler responsibilities

process_init_merchant — Validate authority is signer; derive + create the Merchant PDA (utils::account::create_pda); store authority, payout_owner, mint, access_policy, canonical bump; zero the counter.

process_delegate_settlement — Validate the merchant authority; create the Settlement PDA in Pending and the PaymentNonce PDA (reject if the nonce PDA already exists → MppError::NonceAlreadyUsed, FR12). Then CPI into the Delegation Program via per_cpi::delegate::delegate_account to hand Settlement ownership to the chosen validator (the TEE validator for privacy). Set status Delegated.

process_record_payment — Runs on the ephemeral validator. Metadata-only (resolves §14 Q4): the private value move is performed by the PER/TEE over ephemeral balances, not by an on-chain SPL transfer here. The ER only permits writes to delegated accounts, so a token CPI over the non-delegated payer_ata/payout_ata would fail ER verification ("writable account that cannot be written") and would be public anyway — defeating the privacy goal. The handler validates the perRef binding, status == Delegated, the SettlementMerchant binding, mint integrity (settlement.mint == merchant.mint), and amount > 0, then flips status Delegated → Recorded. Account-level privacy is enforced by the PER access rules registered for the Settlement PDA.

process_commit_settlement — Runs inside the ER. Gate status == Recorded, set status Committed, then CPI per_cpi::commit::commit_and_undelegate_settlement to write ER state back to the Solana base layer and schedule undelegation in a single atomic MagicBlock intent bundle (durable, auditable anchor — PRD §6.2 mechanism 5). There is no commit-only path: every commit also undelegates, and the undelegate arrives later as an automatic Delegation-Program callback (§6.2 process_undelegate, §10).

process_undelegate — Handle the Delegation Program's undelegation callback (per_cpi::undelegate): strip the 8-byte callback discriminator, then process_undelegate_callback authenticates the caller (buffer owned-by + signer), returns PDA ownership to private_mpp, and restores the committed snapshot from the buffer (§8.3). It then performs a read-only gate that the restored status is Committed (else SettlementNotRecorded). It must not mutate the account: the Delegation Program validates that the data after this callback equals the committed snapshot and otherwise aborts the whole undelegation with DlpError::InvalidAccountDataAfterCPI (= 7) — confirmed live on devnet. The terminal "undelegated" fact is observable via ownership returning to private_mpp; any explicit status bump must live in a separate, later instruction (the Undelegated status value is reserved for that).

process_grant_access — Validate merchant authority; create an AccessGrant PDA binding reader + role + expires_at to a Settlement. This is the on-chain, account-scoped authorization the facilitator/auditor presents for scoped reads (PRD §6.2 mechanism 3, FR7, FR10).


7. Entrypoint & Dispatch

src/entrypoint.rs:

use pinocchio::{AccountView, Address, entrypoint, ProgramResult};

entrypoint!(process_instruction);

pub fn process_instruction(
    program_id: &Address,
    accounts: &mut [AccountView],
    instruction_data: &[u8],
) -> ProgramResult {
    // The MagicBlock undelegation callback arrives with an 8-byte discriminator
    // (not our 1-byte tag scheme); detect and route it before normal dispatch.
    if instruction_data.len() >= UNDELEGATE_CALLBACK_DISCRIMINATOR.len()
        && instruction_data[..UNDELEGATE_CALLBACK_DISCRIMINATOR.len()]
            == UNDELEGATE_CALLBACK_DISCRIMINATOR
    {
        return process_undelegate(program_id, accounts, instruction_data);
    }

    let (tag, data) = instruction_data
        .split_first()
        .ok_or(MppError::InvalidInstructionData)?;

    match MppInstruction::try_from(*tag)? {
        MppInstruction::InitMerchant       => process_init_merchant(program_id, accounts, data),
        MppInstruction::DelegateSettlement => process_delegate_settlement(program_id, accounts, data),
        MppInstruction::RecordPayment      => process_record_payment(program_id, accounts, data),
        MppInstruction::CommitSettlement   => process_commit_settlement(program_id, accounts, data),
        MppInstruction::GrantAccess        => process_grant_access(program_id, accounts, data),
        // Undelegate (4) never arrives by tag — the real path is the 8-byte
        // callback discriminator handled above. A literal tag-4 call is invalid.
        MppInstruction::Undelegate         => Err(MppError::InvalidInstructionData.into()),
    }
}

Undelegate dispatch. Undelegate is never reached through the 1-byte tag. The Delegation Program invokes the callback with instruction data UNDELEGATE_CALLBACK_DISCRIMINATOR (8 bytes) ‖ Borsh seeds, so the entrypoint pattern-matches that prefix and routes the whole payload to process_undelegate, which strips the discriminator and calls per_cpi::undelegate::process_undelegate_callback (§8.3).


8. PER CPI Module (the integration-risk surface)

PRD §7.3 / §11: the ephemeral-rollups-sdk is Anchor-macro-first (#[ephemeral], #[delegate], #[commit]). With Pinocchio we hand-build the equivalents in src/per_cpi/. This module is deliberately small and the most heavily tested (PRD risk mitigation: "isolate a small, well-tested per_cpi module").

8.1 per_cpi/delegate.rs

/// Builds and invokes the Delegation Program's `delegate` instruction.
/// Mirrors the SDK's #[delegate] macro: transfers PDA ownership to the
/// Delegation Program, recording the owning program, seeds, and target validator.
pub fn delegate_account(
    payer: &AccountView,
    delegated_pda: &AccountView,     // the Settlement PDA
    owner_program: &Address,         // private_mpp ID
    delegation_program: &AccountView,
    system_program: &AccountView,
    seeds: &[&[u8]],                 // Settlement PDA seeds
    validator: Option<Address>,      // TEE validator pubkey for privacy
    commit_frequency_ms: u32,
) -> ProgramResult;

Internally builds the delegation buffer, delegation record, and delegation metadata PDAs (derived in per_cpi/seeds.rs) and issues the CPI with the program's PDA signer seeds.

8.2 per_cpi/commit.rs

/// CPIs the MagicBlock Magic Program to commit the settlement's ER state to the
/// base layer **and** schedule undelegation in one atomic intent bundle. This is
/// the only commit path the program exposes — there is no commit-without-undelegate
/// variant. Used by `commit_settlement` at finality.
///
/// Account ordering for the CPI:
///   0 — payer         (WRITE, SIGNER)
///   1 — magic_context (WRITE)  — fixed address `MagicContext1111...`
///   2 — settlement    (WRITE)  — referenced by index 2 in CommitTypeArgs::Standalone
pub fn commit_and_undelegate_settlement(
    payer: &AccountView,
    magic_context: &AccountView,
    settlement: &AccountView,
) -> ProgramResult;

The CPI is built not from the older ScheduleCommit / ScheduleCommitAndUndelegate discriminators but from the Magic Program's newer intent-bundle instruction, MagicBlockInstruction::ScheduleIntentBundle (variant index 11). commit.rs mirrors the minimal magicblock-magic-program-api 0.12.0 types locally (CommitTypeArgs, UndelegateTypeArgs, CommitAndUndelegateArgs, MagicIntentBundleArgs) and serialises a bundle with commit_and_undelegate = Some(...) (commit: None), so bincode reproduces the exact wire format without linking the API crate into the on-chain binary (§3.1). The two standing assumptions — ScheduleIntentBundle stays at variant 11, and standalone_actions is always an empty vec![] — are documented in commit.rs and must be re-verified when bumping past magicblock-magic-program-api 0.12.0.

8.3 per_cpi/undelegate.rs

/// Discriminator the Delegation Program uses for the external undelegate callback
/// (verbatim from magicblock-delegation-program-api 3.0.0).
pub const UNDELEGATE_CALLBACK_DISCRIMINATOR: [u8; 8] = [196, 28, 41, 206, 48, 37, 51, 167];

/// Validates the callback caller and applies the post-undelegation state
/// transition (returns ownership to private_mpp). Re-implements the
/// SDK-generated callback handler by hand.
pub fn process_undelegate_callback(
    accounts: &mut [AccountView],
    seeds_data: &[u8],
) -> ProgramResult;

How process_undelegate_callback works

When the ER finishes with a delegated Settlement PDA, the Delegation Program calls back into private_mpp to hand ownership of that PDA back. This handler is the trusted receiver of that callback. It runs in four steps; any failure aborts the whole undelegation and leaves the PDA delegated.

It is dispatched after the entrypoint strips the 8-byte UNDELEGATE_CALLBACK_DISCRIMINATOR; the remaining bytes (seeds_data) are the Borsh Vec<Vec<u8>> of the PDA's registered seeds.

Accounts (in order): [delegated, buffer, payer, system_program, ..]

  • delegated — the Settlement PDA being returned to the program.
  • buffer — the Delegation Program's undelegate-buffer PDA, holding the committed state and the lamports to refund into the recreated account.
  • payer — funds any rent shortfall during recreation.
  • system_program — required by the create/allocate/assign CPIs.
  1. Authenticate the caller. The handler accepts the callback only if the buffer account is owned by DELEGATION_PROGRAM_ID and is a signer. Only the Delegation Program can make its own buffer PDA sign a CPI, so this pair of checks is the on-chain proof that the genuine Delegation Program — not an impostor — invoked us. Otherwise → MppError::InvalidDelegationProgram.

  2. Re-derive and bind the PDA. parse_seed_vec decodes exactly three seed slices (["settlement", merchant, nonce]) from seeds_data, and Address::derive_program_address recomputes the address and its canonical bump under crate::ID. The derived address must equal delegated's address; this rejects a caller trying to launder an unrelated PDA back through us. Mismatch or undecodable seeds → MppError::InvalidPda / MppError::InvalidInstructionData.

  3. Recreate the PDA owned by private_mpp. Using the parsed seeds plus the recovered bump as signer seeds, create_pda performs the full create/allocate/assign dance, sizing the account to the buffer's length. It tolerates the account already carrying the lamports the Delegation Program transferred in, so ownership flips back to the program without losing rent.

  4. Restore committed state. The handler copies the buffer's bytes verbatim into the recreated Settlement account (copy_from_slice), so the PDA comes back holding exactly the state the ER committed.

On success the Settlement PDA is program-owned again with its committed data intact, ready for the calling instruction (process_undelegate) to stamp status Undelegated.

parse_seed_vec::<N> is a zero-allocation Borsh Vec<Vec<u8>> reader: a u32 LE element count (which must equal N) followed, per element, by a u32 LE length and that many bytes. The returned slices borrow directly from seeds_data; truncation or a wrong count yields MppError::InvalidInstructionData. The discriminator and account ordering are pinned (golden-vector test, §13) against magicblock-delegation-program-api 3.0.0's EXTERNAL_UNDELEGATE_DISCRIMINATOR.

8.4 per_cpi/seeds.rs

Helpers to derive the Delegation Program's auxiliary PDAs (buffer, delegation record, delegation metadata) so the delegate/commit CPIs pass the correct accounts. Validated against the published delegation-program interface (PRD §11 mitigation).


9. Errors

src/error.rs:

#[repr(u32)]
pub enum MppError {
    InvalidInstructionData = 0,
    NotInitialized         = 1,
    AlreadyInitialized     = 2,
    InvalidPda             = 3,
    MissingRequiredSigner  = 4,
    InvalidMerchantAuthority = 5,
    InvalidMint            = 6,
    InvalidAmount          = 7,
    NonceAlreadyUsed       = 8,   // FR12 replay guard
    NotDelegated           = 9,
    InvalidDelegationProgram = 10,
    UnauthorizedReader     = 11,
    AccessGrantExpired     = 12,
    SettlementNotRecorded  = 13,
}

impl From<MppError> for ProgramError {
    fn from(e: MppError) -> Self { ProgramError::Custom(e as u32) }
}

10. Lifecycle (mapping to PRD §6.1 / §7.2)

init_merchant ──► (per merchant, once)
                       │
   per payment:        ▼
delegate_settlement ─► record_payment ─► commit_settlement ┄┄(auto callback)┄► undelegate
   (base, delegate      (ER, metadata     (ER → base anchor      (DLP calls back into
    Settlement PDA       anchor; private    + schedules            private_mpp; returns
    to TEE validator)    move is PER/TEE)   undelegation)          PDA ownership)
                       │
grant_access ──────────┘  (scoped read for facilitator/auditor — FR7/FR10)
  1. delegate_settlement corresponds to PRD flow step 5 (delegate→transfer CPI) and reserves the payment_nonce (FR12).
  2. record_payment is metadata-only inside the TEE ER — it anchors the record (Delegated → Recorded); the actual private token move is performed by the PER/TEE over ephemeral balances, not by an on-chain SPL transfer (§14 Q4).
  3. commit_settlement is the durable anchor (PRD step "commit settlement state", mechanism 5). It commits and schedules undelegation atomically (§8.2); the undelegate is not a separate user step — the Delegation Program fires it back into private_mpp as an automatic callback (§6.2 process_undelegate).
  4. The facilitator's /verify + /settle (PRD §A.3) read the Settlement via the AccessGrant, then trigger commit_settlement; transaction returned in PAYMENT-RESPONSE is the base-layer commit signature.

11. Off-chain Components (interface-level)

These consume the program; full specs live in their own crates/packages.

Component Path Responsibility Key functions
PER client sdk/rust/private-mpp-per, sdk/ts/per Wrap the Private Payment API (PRD §5.3). login(signer), transfer({private,mint,to,amount}), read_private_transfer(perRef,tx), submit_and_commit(tx,perRef), is_spent(perRef)
exact-private scheme sdk/ts/svm-exact-private x402 v2 client+server scheme (PRD §A.2/§A.3). client buildExactPrivatePayment(req, per, signer); server ExactPrivateSvmScheme.verify(...) / .settle(...)
Agent SDK sdk/rust/private-mpp-sdk, sdk/ts/agent-sdk x402 client + PER auth + fee-sponsor request. pay(resourceUrl), authenticatePer(), requestSponsorship(tx)
Merchant SDK sdk/ts/merchant-sdk x402 middleware that emits the 402 + registers the scheme (PRD §6.3). paymentMiddleware(routes, server)
Facilitator sdk/services/facilitator Holds the PER read credential; runs /verify + /settle; co-signs as fee payer for gasless (PRD App. B). POST /verify, POST /settle

12. Function Index (on-chain, quick reference)

entrypoint.rs        process_instruction
instructions/        process_init_merchant
                     process_delegate_settlement
                     process_record_payment
                     process_commit_settlement
                     process_undelegate
                     process_grant_access
per_cpi/             delegate_account
                     commit_and_undelegate_settlement
                     process_undelegate_callback
state/               Merchant::{load, load_mut, save, derive_pda}
                     Settlement::{load, load_mut, save, derive_pda}
                     AccessGrant::{load, derive_pda, is_valid}
                     PaymentNonce::{derive_pda, exists}
utils/account.rs     create_pda, assert_owner, assert_signer, close_account
utils/pda.rs         find_program_address, assert_pda
utils/validation.rs  assert_mint, assert_amount, assert_merchant_authority

13. Testing Strategy

Layer Tool Coverage
Integration (per instruction + lifecycle) LiteSVM Each handler: happy path + each MppError; PDA derivation; replay rejection (FR12); real SPL-Token transfer; delegation via a mocked delegation program; access-grant gating.
End-to-end devnet scripts Real Delegation Program + TEE validator; x402 round-trip with the facilitator; gasless co-sign.

The host test harness lives in contract/tests (a standalone package kept out of the workspace so cargo build-sbf stays minimal). Each LiteSVM transaction also prints the compute units it consumed ([CU] <name>: <n>).

13.1 Running the tests

The LiteSVM tests load the compiled program .so (and the mock delegation program), so build the SBF binary first:

# from the repo root
cargo build-sbf --manifest-path contract/programs/private-mpp-program/Cargo.toml
cd contract/tests && cargo test

Show the compute units consumed by each transaction:

cargo test -- --nocapture | grep '\[CU\]'

Shortcut — when the .so is already up to date, just run cargo test from contract/tests (skip the build-sbf step).

The per_cpi module gets dedicated golden-vector tests comparing hand-built instruction bytes against the Anchor-SDK-produced equivalents (PRD §11 mitigation).


14. Open Architectural Questions (from PRD §11)

  1. Validator targeting — confirm the exact account list and parameters the Delegation Program expects for selecting the TEE validator (per_cpi::delegate).
  2. Undelegation callback ABI — pin the UNDELEGATE_CALLBACK_DISCRIMINATOR and account ordering against the live delegation program.
  3. Fee sponsorship boundary — whether the fee payer co-signs at settle (facilitator-as-payer, recommended for M3) or via a separate paymaster affects which signer the program must tolerate on commit_settlement.
  4. PER ↔ on-chain record reconciliationResolved (2026-06-21). record_payment is a program instruction that runs inside the ER but is metadata-only: the Private Payment API (PER/TEE) performs the actual private token move over ephemeral balances; the program only anchors/commits metadata on the Settlement PDA. Confirmed live on devnet — the ER rejects writable non-delegated token accounts ("Transaction loads a writable account that cannot be written"), and a plaintext base-layer transfer would break privacy. See instructions/record_payment.rs.

15. Transactions (devnet proof-of-life)

A captured run of the full-lifecycle end-to-end script (contract/tests/devnet/exercise.mjs) against the deployed program AGVBmw3SWNBoaNG9yCJtT5H2NBaWfQdqAA8gzw9BMWrE. Every step is a real on-chain transaction — nothing is mocked or run on a local validator. The base-layer (devnet) txs are on Solscan; the ER (MagicBlock) txs resolve via the Solana Explorer pointed at the rollup RPC. The terminal undelegate is the automatic Delegation-Program (DLP) callback returning PDA ownership to the program with the committed snapshot intact.

Step Layer Transaction
1 · init_merchant base (devnet) 2MgCEbjT…obXHmWZ
2 · delegate_settlement base (devnet) 2C6SwGL1…GTjg5FU
3 · grant_access base (devnet) 5KzgShbv…HHomL
4 · record_payment ER (MagicBlock) 5C5BUkfx…tQ4z7
5 · commit_settlement ER (MagicBlock) bJrLcJo5…WQc1t
6 · undelegate (DLP callback) base (devnet) settlement PDA E6Bpr1Gz…NVp8T — owner returned to program

Settlement PDA: E6Bpr1GznVB1aLM4hoDNXxHpXM7kewaAGpN6rgdNVp8T · payment nonce: 0x6b1385d331e463d41d8187375599a8e57cb036c8c1e3a11541b3623101cd5eef

Raw run output:

$ node exercise.mjs
funder: 3njzSa5GMB7nPyP4xwKdmMS9KMhc7DF3yjHhcFG5YTSy
program: AGVBmw3SWNBoaNG9yCJtT5H2NBaWfQdqAA8gzw9BMWrE
ER RPC: https://devnet.magicblock.app
funder balance: 16.731195418 SOL

authority (fresh): 5jxazn2WszS3qv66WrGZXNYQUSvLeEGaswREjydVd2mh
  funded authority with 0.08 SOL: https://solscan.io/tx/4WbxBKPGLMMafnWQHW1y5VkpfqPfKtFcrqJD6N5QynxX3NfRejxT1SR2ETn1h4WMXRUVhraUbV7GuauQK7UisJq1?cluster=devnet
  mint: 6WVpLY1u5XXbYBkvrn5me4AL91sf8mbWcwSDdW9Xcs6T
merchant PDA: qSKoA6TxULshPL4C96Yg3hHerjY5HPdr3Cn8i8jhP6W bump 255

[1] init_merchant tx: https://solscan.io/tx/2MgCEbjTfSiQG8hNAmeL1QC7oGzzaADo1kksC9AkGnFU7ti6W5fFVnfNq94Ytib9ekhhrwfsMxQGYwNMDobXHmWZ?cluster=devnet
[2] delegate_settlement tx: https://solscan.io/tx/2C6SwGL1DWAyJNrLi23FAMNosWuiiJJ8LY5DCqPnCaWL8dnGyJcmTh4qfpTJDJ6GNtBeY5kE3PCjj89unGTjg5FU?cluster=devnet
   settlement PDA: https://solscan.io/account/E6Bpr1GznVB1aLM4hoDNXxHpXM7kewaAGpN6rgdNVp8T?cluster=devnet
   nonce: 0x6b1385d331e463d41d8187375599a8e57cb036c8c1e3a11541b3623101cd5eef
   settlement owner after delegate: DELeGGvXpWV2fqJUhqcF5ZSYMS4JTLjteaAMARRSaeSh (delegated -> owned by delegation program)
[3] grant_access tx: https://solscan.io/tx/5KzgShbvcAsajm1qGxHXcZywzLNnRuBBdNGdHyccwHes2wzpNMgNjuP9bXWxk7qJwbyeZdA1GUMnj8bABLsHHomL?cluster=devnet
   access_grant PDA: https://solscan.io/account/DKMuypHGdPR8N4Sy1EPW3aVEYUm3DHEC7xDCBJkJBT4J?cluster=devnet
[4] record_payment (ER) tx: https://explorer.solana.com/tx/5C5BUkfx1KML6dknXwcTNWkUZ28LRzrAPf14x2PeTiusVxSabn1oq724MrRxJ1taVeKJy18vDyvzHAydoRttQ4z7?cluster=custom&customUrl=https%3A%2F%2Fdevnet.magicblock.app
   settlement status on ER: RECORDED
[5] commit_settlement (ER) tx: https://explorer.solana.com/tx/bJrLcJo5vyTxrbgFr527p3wabFyiH12XZrma5dSoPHPZrENNwxcuD57wH74AAGxDLotKn3CN1iH9nKyidRWQc1t?cluster=custom&customUrl=https%3A%2F%2Fdevnet.magicblock.app
[6] waiting for undelegate callback on base layer...
[6] undelegated — owner=program, committed snapshot intact (status=COMMITTED)
   settlement (base): https://solscan.io/account/E6Bpr1GznVB1aLM4hoDNXxHpXM7kewaAGpN6rgdNVp8T?cluster=devnet

full lifecycle exercise complete

16. Test Suite (local LiteSVM run)

A captured run of the integration test suite (§13) against the compiled program .so via LiteSVM, from contract/tests. Every handler is covered by a happy path plus its failure modes (missing signer, replay rejection, mint/amount validation, delegation checks, access-grant gating, undelegate-callback authentication). All 33 tests pass.

cargo test — 33 passing

Suite Tests Coverage
commit_settlement 4 happy path + status gating (pending/delegated/uninitialized)
delegate_settlement 6 happy path + signer, foreign authority, owner/delegation-program checks, replay rejection
grant_access 7 happy paths (expiry / no expiry) + authority, settlement binding, expiry, signer checks
init_merchant 5 account creation + signer, re-init, non-token mint, distinct authorities
record_payment 5 happy path + amount, delegation, nonce, mint validation
undelegate 6 callback happy path + buffer signer/owner, seed-mismatch, discriminator, status checks
Total 33 all passing

17. Standalone PER Private-Payment Proof (devnet)

contract/tests/devnet/per.js exercises the pure Private Payment API (PER) flow end-to-end on devnet — no contract, no Settlement PDA. It is the concrete proof-of-life for the leg §14 Q4 delegates to the PER/TEE: the actual private value move that the on-chain record_payment only anchors as metadata. A repo-root launcher runs it from anywhere with node per.

It mirrors the project's real scheme (sdk/.../exact-private-client): a private transfer is ephemeral→ephemeral with gasless, and both parties operate on delegated ephemeral vaults — the recipient only materialises on the base layer when it withdraws. The TEE validator is MTEWGuqx… (the PER/TEE, distinct from the standard ER used by the contract's exercise.mjs).

Step Layer What happens
D1 initialize-mint base register the mint with the TEE ER validator
D2 deposit (payer) base payer base ATA → payer's delegated ephemeral vault
D3 deposit (merchant) base merchant self-deposits a seed → its own vault (required; without a delegated recipient vault the transfer fails InvalidWritableAccount)
D4 readiness (REAL) sends the real private transfer, retrying until the TEE accepts (no simulation)
D5 transfer ER (TEE) 🔒 PRIVATEsendTo:ephemeral, opaque/confidential (the only private leg)
D6 withdraw (merchant) base merchant ephemeral → base; arms a delegation shuttle
D7 settle base TEE settles via UndelegationCallback + SettleAndCloseShuttleIntent, crediting the merchant's base ATA (~3s after D6)

Key finding (verified live). Routing value as ephemeral→base does not deliver on devnet — it debits the payer but leaves an orphaned pending base credit the TEE never commits (0 after 15 min). Routing through the merchant's own ephemeral vault + an explicit withdraw is what actually delivers: confirmed on-chain with payer −500000 and merchant +500000 on devnet base (the credit lands in the TEE UndelegationCallback settle tx, not the withdraw tx).

17.1 Running it

node per          # repo root (launcher) or from contract/tests/devnet

Env vars: WALLET (funder, default ~/.config/solana/id.json) and MERCHANT_KEYPAIR (default <repo-root>/merchant-wallet.json, gitignored). The SPL mint is created fresh every run (a value-less test token), and the merchant is a fixed, reusable wallet so the delivered tokens land somewhere you control. In a real integration you would use an existing mint (e.g. USDC), a funded payer, and a merchant that already has a PER vault (so D3's bootstrap and the per-run mint would not apply).

Layer note. D1/D2/D3/D6 and the D7 settle are real Solana devnet base txs (Solscan-verifiable); only D5 runs inside the devnet TEE ER rollup (devnet-tee.magicblock.app) and is intentionally not on the base ledger — that opacity is the privacy guarantee.


About

Private MPP proposes a private, gasless payment protocol for AI agents on Solana.

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors