Skip to content
Integra Agent Guard

MPP

The decoded charge-request body — and why nothing in it can tell you it is MPP.

import { parseProposalFromMppRequest } from "@integraledger/agent-guard";

declare const request: unknown; // the DECODED `request` auth-param body

const proposal = parseProposalFromMppRequest(request, {
  level: 3,
  sellerAssurance: "domain-controlled",
});

The document is the body, not the challenge

MPP's identity lives one layer out, in the WWW-Authenticate: Payment challenge's auth-params — realm, method, id, request, expires, digest, opaque.

The document this parser reads is the base64url(JCS(JSON)) payload that the challenge's request auth-param carries. A caller holding a decoded body is expected to have decoded it from there.

Why it must be named

MPP is the one protocol recorded as undiscriminable in the discriminant table. That is a finding with a citation, not a hole.

The charge-intent body's members are amount and currency (both required strings) plus optional recipient, description, externalId and methodDetails. Not one of them names MPP — and an amount/currency pair is the shape of almost every payment document there is.

import { detectProtocol } from "@integraledger/agent-guard";

declare const request: unknown;

detectProtocol(request); // undefined — and PROTOCOL_DISCRIMINANTS records why, with the citation

Adding an mpp row to the universal dispatch map would not make it reachable. It would make the map claim a discrimination it cannot perform.

expires is an auth-param only. The specification states that expiry is conveyed in the challenge and that request objects MUST NOT duplicate it — so a body carrying one is malformed rather than discriminating.

What it reads

Proposal fieldSource
advertisedAtrHashmethodDetails.atrHash
legalContextUrlmethodDetails.legalContextUrl
offer.amountamount, a decimal base-unit integer string
offer.unitcurrency

Both methodDetails fields are the ones the MPP placement declares, which is what makes the round trip compose.

methodDetails is optional in the charge intent, so its absence is a refusal that says so — a request body with no methodDetails is a perfectly conformant MPP document that simply advertises no LCP reference, and saying that is more useful than a schema path error.

Base units, and why a decimal amount is refused

The grammar is the host's, not this library's. The charge-intent draft defines amount as the payment amount in base units — the smallest denomination of a currency or asset; for USD, cents. Every example in the specification agrees: "5000"/usd, "1000000"/token, "100000"/sat.

So a decimal amount is malformed MPP, and it is refused at the parser rather than thrown from inside the gate — where BigInt("10.50") would throw and escape the gate's contract to return a decision.

The carrier is a bare value

The MPP placement declares methodDetails.atrHash with encoding: "bare-value" and carrierTypes: ["sha256"] — the wire carries the raw hash, not an lcp:sha256:0x… string.

That is why this parser validates the hash itself through the kernel's own ATR-hash predicate, as the x402 parser validates its raw extra.atrHash, and why it does not decode through the reference codec as the ACP parser does. Using the kernel's predicate rather than restating its regex keeps one definition of what an ATR hash is.

Edit on GitHub

Last updated on

On this page