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. |
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.
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 isAccountView(notAccountInfo), address type isAddress(fromsolana-address), data access is viatry_borrow/try_borrow_mut(the latter requires&mut AccountView), and the entrypoint handler receivesprogram_id: &Addressandaccounts: &mut [AccountView]. Pinocchio 0.11 is built on the splitsolana-*-view 2.xcrates (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.
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 onpinocchio-pubkey. Its latest version (0.3.0) still pinspinocchio ^0.9(i.e.>=0.9, <0.10), which would resolve a secondpinocchio(0.9.x) into the tree alongside 0.11 and break type compatibility forAddress/AccountView. No replacement crate is needed:Address::from_str_const("…")(fromsolana-address2.x, re-exported at the crate root aspinocchio::Address— there is nopinocchio::pubkeymodule in 0.11) decodes base58 program IDs at compile time. Revisit if/when apinocchio-pubkeyrelease bumps topinocchio ^0.11.
MagicBlock API crates as reference, not runtime weight.
magicblock-delegation-program-api/-magic-program-apidepend onsolana-program 3.x;magicblock-delegation-program-apiadditionally pulls an olderpinocchio 0.10(andpinocchio-pubkey 0.3→pinocchio 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 normalis empty). To keep the program minimal (PRD §7.3), prefer copying the relevant discriminators and PDA seeds intoper_cpi/seeds.rsasconsts 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].
[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.11src/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;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_bumponDelegateSettlement); 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.
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).
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
}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.
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)
}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).
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.
mintandauthorityforInitMerchantcome from accounts, not the data. The trailingbumpfields are the client-derived canonical bumps described in §4.
| 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 |
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 Settlement↔Merchant 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).
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.
Undelegateis never reached through the 1-byte tag. The Delegation Program invokes the callback with instruction dataUNDELEGATE_CALLBACK_DISCRIMINATOR (8 bytes) ‖ Borsh seeds, so the entrypoint pattern-matches that prefix and routes the whole payload toprocess_undelegate, which strips the discriminator and callsper_cpi::undelegate::process_undelegate_callback(§8.3).
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").
/// 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.
/// 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.
/// 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;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— theSettlementPDA 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.
-
Authenticate the caller. The handler accepts the callback only if the
bufferaccount is owned byDELEGATION_PROGRAM_IDand 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. -
Re-derive and bind the PDA.
parse_seed_vecdecodes exactly three seed slices (["settlement", merchant, nonce]) fromseeds_data, andAddress::derive_program_addressrecomputes the address and its canonical bump undercrate::ID. The derived address must equaldelegated's address; this rejects a caller trying to launder an unrelated PDA back through us. Mismatch or undecodable seeds →MppError::InvalidPda/MppError::InvalidInstructionData. -
Recreate the PDA owned by
private_mpp. Using the parsed seeds plus the recovered bump as signer seeds,create_pdaperforms 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. -
Restore committed state. The handler copies the buffer's bytes verbatim into the recreated
Settlementaccount (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.
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).
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) }
}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)
- delegate_settlement corresponds to PRD flow step 5 (delegate→transfer CPI) and reserves the
payment_nonce(FR12). - 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). - 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_mppas an automatic callback (§6.2process_undelegate). - The facilitator's
/verify+/settle(PRD §A.3) read theSettlementvia theAccessGrant, then triggercommit_settlement;transactionreturned inPAYMENT-RESPONSEis the base-layer commit signature.
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 |
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
| 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>).
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 testShow 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).
- Validator targeting — confirm the exact account list and parameters the Delegation Program expects for selecting the TEE validator (
per_cpi::delegate). - Undelegation callback ABI — pin the
UNDELEGATE_CALLBACK_DISCRIMINATORand account ordering against the live delegation program. - 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 oncommit_settlement. - PER ↔ on-chain record reconciliation — Resolved (2026-06-21).
record_paymentis 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 theSettlementPDA. 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. Seeinstructions/record_payment.rs.
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
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.
| 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 |
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) | 🔒 PRIVATE — sendTo: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).
node per # repo root (launcher) or from contract/tests/devnetEnv 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.
