@bolyra/mpp
Verify an agent's delegated spend mandate before accepting an MPP payment credential.
MPP (Machine Payments Protocol) gives machines a payment
interface: Request → 402 Challenge → payment Credential → Verification +
Payment-Receipt. It answers "did this client pay?" — deliberately not
"was this agent authorized to spend?". When the paying client is an
autonomous agent rather than its operator, that second question is the missing
precondition. This package adds it as a small authorization middleware for
mppx servers: the agent presents an
operator-signed Bolyra spend mandate in a request header, and the gate
verifies it — fail-closed — before the MPP payment flow proceeds.
How the two protocols compose (without modifying either) is mapped in
Bolyra as an Authorization Companion to MPP.
One line: MPP moves the money; Bolyra proves the mandate.
This is a community-built integration. It is not affiliated with, endorsed
by, or sponsored by the MPP authors, wevm, Tempo, or Stripe.
Install
Into an existing mppx server:
npm install @bolyra/mpp
Starting fresh? Install both:
npm install @bolyra/mpp mppx
mppx is an optional peer dependency — this package never imports it at
runtime; it wraps the method objects you already build with mppx. The test
suite runs against real mppx (currently 0.8.12).
Quickstart
See it run first — one command, nothing else to install:
npx @bolyra/mpp demo
Under two minutes, fully in-process: an operator issues a small-tier spend
mandate (issueMandate), an agent presents it to a route gated by
bolyraGate, a $25 spend allows, a $500 spend denies with the RFC
9457 problem body before any payment logic runs, a mandate-less request
denies, and the ES256K-signed authorization receipt is verified. The
verification path is the real shipped code; only the route is a clearly
labeled stub standing in for an mppx method (for the same flow against real
mppx, see examples/mandate-demo).
This snippet shows the integration shape (placeholders like secretKey and the
operator pubkeys are yours to fill in); for a copy-paste-runnable version with
a mock agent and real values, see examples/mandate-demo.
The adapter wraps Method.Server before it is passed to Mppx.create(), so
no middleware changes are needed and every mppx framework adapter (Express,
Hono, Elysia, Next.js) is covered automatically:
import { Mppx, tempo } from 'mppx/server'
import { bolyraGate } from '@bolyra/mpp'
const tempoCharge = tempo({
currency: '0x20c0000000000000000000000000000000000000',
recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00',
})
const gatedCharge = bolyraGate(tempoCharge, {
audience: 'api.merchant.example',
verifier: {
kind: 'classical',
trustedOperators: [{ x: '<operator pubkey x>', y: '<operator pubkey y>' }],
},
})
const mppx = Mppx.create({ methods: [gatedCharge], secretKey })
export async function handler(request: Request) {
const result = await mppx.charge({ amount: '25' })(request)
if (result.status === 402) return result.challenge
return result.withReceipt(Response.json({ data: '...' }))
}
The agent carries its mandate presentation (a bvp/1 bundle, base64url JSON)
in the X-Bolyra-Authorization header on every request. Denials return RFC
9457 Problem Details (application/problem+json) with a stable machine-readable
code, before any challenge is issued or payment logic runs:
{
"type": "https://bolyra.ai/problems/mpp/scope-exceeded",
"title": "Spend Exceeds Delegated Tier",
"status": 403,
"detail": "required scope exceeds the credential scope",
"code": "scope_exceeded"
}
On allow, the mppx receipt (and therefore the Payment-Receipt header) gains
a bolyraAuthorization extension field — tier, amount, verifier kind, and the
ES256K-signed, hash-chained authorization receipt reference — giving the
approved → paid audit pair described in the companion note.
Issuing the mandate (operator side)
The presentation the agent carries is minted by the operator with the Bolyra
CLI — bolyra mandate issue
— not hand-assembled. The operator signs a request binding for one agent, one
audience, and one financial tier, and the CLI prints the exact bvp/1
presentation this gate verifies:
bolyra key generate --out operator.key
bolyra mandate issue \
--operator-key operator.key \
--agent shopper-bot \
--audience api.merchant.example \
--model opus-4.1 \
--tier small \
--expiry 30d
The operator public key printed on stderr is exactly what you configure as a
trustedOperators entry in the gate. This is issuance, not key management or
a wallet: the operator key is one you already hold; bolyra mandate issue
never generates, stores, or rotates keys, holds funds, or settles payments — it
signs one standing spend mandate. @bolyra/mpp's test fixtures mint through the
same issuance path (issueMandate), so there is one code path, not two.
In classical mode the operator signature binds the request binding
({agent, audience, program, model, capabilities, expiry} — binding v2), so both
the spend ceiling (signed capability tier) and the time bound (expiry,
pinned equal to the credential expiry) are tamper-evident: a presenter can no
longer re-anchor a later expiry on an issued mandate. The permission_bitmask
remains a self-asserted consistency field; sound bitmask enforcement still needs
the zk-class verifier. See "What is and isn't checked" below.
Amount → tier mapping
The route's amount is resolved to USD and mapped to the cumulative
financial-tier bits of @bolyra/sdk's Permission model. Comparison is
exact-decimal (never float); boundaries are strict:
< 100 | mpp:financial:small | FINANCIAL_SMALL |
100 … < 10,000 | mpp:financial:medium | FINANCIAL_SMALL + FINANCIAL_MEDIUM |
>= 10,000 | mpp:financial:unlimited | all three financial bits |
An operator delegating up to the medium tier signs the binding with
capabilities: ["mpp:financial:small", "mpp:financial:medium"] — higher tiers
list the lower ones, mirroring the cumulative bit encoding. By default
amount is read as a decimal USD string (the mppx.charge({ amount: '1' })
convention); pass amountToUsd when your route prices in token base units or
another currency. Unresolvable amounts fail closed.
Configuration
audience | string | required | Byte-literal match against the mandate's signed project_key (payee binding) |
verifier | VerifierConfig | required | classical (in-process), command (EVC v1 spawn), or url (hosted verifier) |
verifier.trustedOperators | {x, y}[] | required for classical | Decimal-string operator pubkeys; empty set fails closed |
program | string | "mpp" | Binding program discriminator |
model | string | echo bundle | Optional model pin; when set, the signed binding must name it |
amountToUsd | (ctx) => string | number | options.amount as USD | Resolve route amounts for tier mapping; errors fail closed |
enforce | "always" | "payment" | "always" | "payment" skips gating on credential-less challenge probes |
header | string | x-bolyra-authorization | Request header carrying the presentation; Authorization is rejected (MPP's payment credential rides it) |
nonceStore | NonceStoreLike | in-memory | Reserve-before-act store for host-nonce-mode verifiers; inject a shared, durable store for multi-instance deployments |
receipts | {issuer?, keyId?, privateKey?} | ephemeral key | ES256K decision receipts; pin a key in production |
onReceipt | (receipt) => void | — | Sink for every signed decision receipt (allow and deny) |
Verifier backends:
verifier: {
kind: 'command',
command: 'bolyra',
args: ['verify', '--roots', 'roots.json', '--capability-map', 'mpp-capabilities.json'],
}
verifier: { kind: 'url', url: 'https://…/v1/verify', token: process.env.BOLYRA_VERIFY_TOKEN }
Both external modes speak the
External Verifier Contract v1
(one JSON request in, one fail-closed verdict out) and implement the host
obligations: 10s default timeout, stdout/response-body caps (1 MiB), strict
single-object closed-schema verdict parsing (unknown members and unrecognized
kind values reject), and reserve-before-act nonce handling. Every
verifier failure class — timeout, crash, garbage output, unreachable
endpoint, oversized response — denies with internal_error; a broken
verifier is never an allow.
What is and isn't checked (read this)
The default verifier is classical — the same classical pipeline as the
Bolyra hosted-verify preview, run in-process. It does not verify
zero-knowledge proofs, so every public signal and credential field in the
bundle is self-asserted. The one cryptographically load-bearing fact is the
operator's EdDSA-Poseidon signature over the request binding. A classical
allow means, and only means:
A configured trusted operator signed a binding authorizing this exact
{agent_name, project_key, program, model, capabilities, expiry}, the request
matches that signed binding, and the granted capability (the amount's
financial tier) is a subset of it.
Checked (classical):
- trusted-operator gate (
trustedOperators; empty set fails closed)
- EdDSA-Poseidon binding signature against that operator key (binding v2 — the
signed binding includes
expiry)
- signed
binding.expiry == credential.expiry (binding v2); an obsolete
five-field v1 binding is rejected unsupported_version
- byte-literal request↔binding match —
project_key is your audience
- granted tier capability ⊆ operator-signed capabilities
- consistency checks on the revealed credential: Poseidon scope anchoring,
model-hash binding, cumulative permission-bit subset, strict expiry
(
now == expiry is expired) over the signature-bound expiry
Not checked (classical):
- Groth16 proof verification, Merkle-root inclusion, human-uniqueness, and
delegation-chain proofs — bundles carrying zk-only slots are denied,
not half-verified; use a zk-class external verifier (
bolyra verify) via
verifier: { kind: 'command', … } for those
- sound permission-bitmask enforcement against a malicious trusted operator:
the revealed
permission_bitmask is a self-asserted consistency field (the
scope commitment is recomputable from public inputs). expiry, by contrast,
IS tamper-evident as of binding v2 — signed and pinned to the credential
expiry — so a re-anchored expiry no longer verifies. For sound bitmask/scope
enforcement use the zk-class verifier (bolyra verify).
- replay: a spend mandate is a standing authorization, reusable within tier
and expiry by design; per-payment idempotency is MPP's challenge binding.
(External verifiers in host nonce mode DO make presentations one-shot —
the gate reserves their
consume_nonces before acting. The default
reservation store is in-memory and per-process: it does not survive
restarts or span instances — inject nonceStore for that.)
- dynamic pricing: the tier check reads the route's configured amount at
preflight time, before any method
request hook runs. For standard methods
mppx pins the economic request fields across calls (stable binding), so the
configured amount is authoritative; if you build a custom method whose
request hook changes the amount, make amountToUsd resolve the
authoritative price — the gate cannot see post-hook values.
agent_name and model (unless pinned via model) are echoed from the
presented bundle — they identify, they don't restrict. The load-bearing
host-asserted fields are audience and the amount tier.
- payment validity itself — that is mppx's job, which runs after the gate
Scope: HTTP request flows. If mppx's payment verification is somehow reached
without a gate decision for that request (standalone verifyCredential()
calls, non-HTTP transports), the wrapped verify fails closed.
Example
A self-contained runnable demo — mppx server + this gate, a mock agent with a
delegated small-tier mandate issued by the real bolyra mandate issue CLI, an
allowed $25 spend and a denied $500 spend — lives in
examples/mandate-demo. It shells out to the CLI, so
build the CLI first:
(cd ../cli && npm install && npm run build)
cd examples/mandate-demo && npm install && npm run demo
License
Apache-2.0 — see LICENSE and NOTICE.