@tollstile/mpp
v0.1.2
Published
Machine Payments Protocol (MPP) for APIs and MCP tools: Stripe and Tempo charges with WWW-Authenticate: Payment challenges and receipts. A Tollstile rail.
Maintainers
Readme
@tollstile/mpp
Machine Payments Protocol rails for Tollstile: WWW-Authenticate: Payment challenges and credentials over HTTP and MCP, with three payment methods.
| Rail | MPP method / intent | Status |
|---|---|---|
| mppStripe() | stripe / charge (Shared Payment Tokens) | Verified in Stripe test mode |
| mppTempo() | tempo / charge (TIP-20 transfer, pull and push) | Stable wire, fakes only |
| mppTempoSession() | tempo / session v2 (payment channels, voucher action) | Experimental |
npm install tollstile @tollstile/mppimport { createTollstile } from "tollstile";
import { mppStripe, mppTempo } from "@tollstile/mpp";
import { tollstile } from "@tollstile/hono";
const toll = createTollstile({
rails: [
mppStripe({
realm: "api.example.com",
secret: process.env.MPP_SECRET!, // binds challenge ids; ≥ 32 chars, list to rotate
secretKey: process.env.STRIPE_SECRET_KEY!,
networkId: "profile_1MqDcVKA5fEO2tZvKQm9g8Yj",
}),
mppTempo({
realm: "api.example.com",
secret: process.env.MPP_SECRET!,
rpcUrl: "https://rpc.moderato.tempo.xyz",
chainId: 42431,
recipient: "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00",
token: { address: "0x20c0000000000000000000000000000000000000", code: "pathUSD" },
denomination: "USD",
}),
],
ledger, // e.g. postgresLedger(db)
secret: process.env.TOLLSTILE_SECRET!,
});
app.get("/report", tollstile(toll.price("$1.00")), (c) => c.json({ ok: true }));Wire format (shared by every rail)
- Challenge: one
WWW-Authenticate: Payment id, realm, method, intent, request, expires, opaqueper rail.requestandopaqueare base64url (no padding) of RFC 8785 JCS JSON.expiresis always set (the quote's expiry, rounded down to the second).headeranddescriptionare never issued, so the credential is always read fromAuthorization. - Binding:
id = base64url(HMAC-SHA256(secret, realm|method|intent|request|expires|digest|opaque))viacrypto.subtle, matching the spec's recommended slots and mppx's published vectors. The first secret signs; every secret in the list verifies (rotation). Ids are compared in constant time against all secrets. - Quote carriage: Tollstile's signed quote token travels in
opaqueas{"tollstile_quote": …}. It is bound by the HMAC and opened withterms.openQuote, so the quoted price is what is charged. - Verification of a credential: HMAC id → realm → expiry → quote → the echoed
requestmust be byte-identical (JCS) to what this server issues for that quote → method-specific proof. Credentials for anothermethod/intentareabsent, so several MPP rails share one request. - Receipts:
Payment-Receipt(base64url JCS JSON, withchallengeId) plusCache-Control: privateon HTTP;_meta["org.paymentauth/receipt"]on MCP. - MCP: credentials from
_meta["org.paymentauth/credential"](nativerequestJSON is accepted);challenge.mcpis{ style: "mpp", challenge }for the adapter's-32042error. - proofId is the challenge id (single-use enforced by the ledger), except for sessions, where it is the channel id.
- Idempotency key: the charge rails return the challenge id as
idempotencyKey(a clientIdempotency-Keytakes precedence). A challenge is issued for one 402 and paid once, so every presentation of its credential is the same logical request: a retry after success answers409 already_paidwith the settlement reference, a retry while the outcome is unknown answers503 payment_outcome_unknown, and a retry after a release runs again. For Stripe it is also the key the PaymentIntent is deduplicated on. Sessions return none: one channel pays many requests, so only the client can say which requests are retries. - Rejected but already paid: when a credential this server issued can no longer be accepted (its challenge or quote expired, or a Tempo pull transaction is past
validBefore), the charge rails still return itsproofId, so a retry of a request that was paid is answered from the ledger (409) instead of a402asking the client to pay again. Sessions do not, since their proof id is the channel. - Payers are canonical:
did:pkh:eip155:<chainId>:<lowercase address>for Tempo charge and session,stripe:<challengeId>for Stripe.
mppStripe(options)
| Option | Default | Purpose |
|---|---|---|
| realm, secret | required | Challenge realm and HMAC secret(s) |
| secretKey | required | Stripe API key, sent only as Authorization: Bearer |
| networkId | required | Stripe Business Network Profile id (methodDetails.networkId) |
| paymentMethodTypes | ["card"] | methodDetails.paymentMethodTypes |
| apiVersion | "2026-07-29.preview" | Stripe-Version. SPTs require a preview version (mppx 0.9.3 uses this one) |
| sptParameter | "shared_payment_granted_token" | The MPP spec / mppx name. Stripe's SPT guide shows payment_method_data[shared_payment_granted_token]; switch if your account needs it |
| searchLagMs | 10 minutes | How long a Stripe Search miss is not trusted after an ambiguous settlement |
| apiBase, fetch, clock | Stripe, global, system | Injection points |
| Capability | Value | Why |
|---|---|---|
| flows | ['upfront'] | Confirming a PaymentIntent captures immediately; there is no hold to release |
| authorization | single | One SPT, one payment |
| refund / partialRefund | true / true | Stripe Refunds API, idempotency key = operation.key |
| variableAmount | false | The SPT is granted for the challenged amount |
| quotes | true | Carried in opaque |
| lookup | true | See below |
- Offers:
nullfor currencies without a known minor unit, amounts finer than the minor unit (sub-cent USD), and amounts below Stripe's general minimum (USD $0.50, GBP £0.30, …). A route priced below the minimum withmppStripeas its only rail answers402with an emptyaccepts; add a rail that can serve small amounts. - Idempotency key:
tollstile_mpp_<challengeId>, not the spec's${challenge.id}_${spt}and notoperation.key. A single challenge may be presented again after its charge was released (the core retry path). A per-charge key would let that retry create a second PaymentIntent; a key containing the SPT would do the same if the payer retried with a new SPT. One key per challenge means Stripe replays the first PaymentIntent (or answers an idempotency conflict, which is treated as ambiguous) instead of charging twice. Challenges expire in minutes, well inside Stripe's 24-hour key retention. Parameters are identical across retries (metadata holds onlychallenge_idandtollstile_authorization), so replays succeed. A replayed PaymentIntent is judged by its status. - The SPT never reaches the ledger. It is a bearer token, so it stays in process memory, keyed by request, until its challenge expires (so a repeated
settlereplays the PaymentIntent through Stripe's idempotency). Another process cannot settle with it, and does not need to: the upfront flow never re-settles from reconciliation, it looks up. - Lookup without creating a charge: retrieve by PaymentIntent id when the charge has one; otherwise Stripe Search
metadata['challenge_id']:'<id>', re-checking the metadata of every hit.processing/requires_capturestay unknown. Refunds are found bymetadata.tollstile_charge. - Eventual-consistency risk: Stripe Search is typically current within a minute but can lag longer during incidents. A miss younger than
searchLagMs(measured from the charge's last transition) stays unknown. If Search lags longer than that, reconciliation releases the charge while a PaymentIntent exists: the payer is charged and the ledger says released. A retry of the same credential still replays that PaymentIntent (no second charge), but without a retry it is only visible in Stripe. KeepsearchLagMsgenerous and reconcile Stripe payouts against the ledger.
mppTempo(options)
| Option | Default | Purpose |
|---|---|---|
| realm, secret | required | Challenge realm and HMAC secret(s) |
| rpcUrl, chainId | required | Tempo JSON-RPC (4217 mainnet, 42431 Moderato) |
| recipient | required | Payee address |
| token | required | TIP-20 { address, code } (6 decimals) |
| denomination | required | Price currency the token is worth at par, e.g. "USD". Other currencies get no offer |
| modes | ["pull"] | "push" is opt-in (see below) |
| splits | none | (amount) => [{ recipient, amount, memo? }] in base units; sum must stay below the total |
| validityMarginMs | 60 s | Block-timestamp skew allowed past a transaction's validBefore |
| fetch, clock | global, system | Injection points |
| Capability | Value | Why |
|---|---|---|
| flows | ['authorization'] | A pull transaction can be broadcast any time before validBefore, so it is broadcast only after the handler succeeds; a failed handler costs the payer nothing |
| authorization | single | One transfer |
| refund | false | Refunding would require the rail to sign transfers with a merchant key |
| variableAmount | false | The signed amount is fixed |
| quotes | true | Carried in opaque |
| lookup | true | eth_getTransactionReceipt by the transaction hash |
- Challenge binding on-chain: every request carries
methodDetails.memo = keccak256("tollstile/mpp:<realm>:<quote id>:<quote nonce>"), and the primary transfer must betransferWithMemowith it. A transfer made for one challenge cannot satisfy another, so one on-chain payment cannot be presented twice under two challenge ids. - Pull verification (offline): strict RLP decode of the
0x76envelope, secp256k1 sender recovery (low-s), chain id,validBeforepresent, in the future and not after the challengeexpires,validAfternot in the future, and the calls must be exactly the required transfers on the token (primary with memo, plus splits). Refused as local policy: fee-payer sponsorship (feePayeris never offered), key authorizations, authorization lists, non-secp256k1 signatures, and extra calls. - Signed transaction in the ledger: stored in authorization data so settlement survives a crash, and dropped by
redactwhen a charge becomes final (the hash andvalidBeforestay for lookup). A released charge keeps it, so the same credential can be retried. Settling again after redaction answers from the transaction receipt. - Settlement:
eth_sendRawTransactionSync. Rebroadcasting the same bytes cannot transfer twice (one nonce). A lost answer or a refusal without a receipt stays unknown until the transaction can no longer be included (validBefore+ margin); only then is it rejected. - Residual risk (authorization flow): between verification and broadcast the payer can spend the nonce or the balance. The handler has then run unpaid; the charge ends
failed/completedandonEventreportsSETTLEMENT_REJECTED. The window is the handler's duration. Balance simulation before admission is not implemented. - Push mode (
modes: ["pull", "push"]): the payer broadcasts and sends the hash; the receipt'sTransfer/TransferWithMemologs are checked at verification, andverifyreturnssettledwith the transaction hash. Core records the charge with flowupfront(money moved before the handler, even though the rail declares onlyauthorizationfor pull mode) assettled/runningbefore the handler. Because this rail cannot refund, a failed handler leaves the chargesettled/failedwith aREFUND_REJECTEDevent, and reconciliation skips it (RECONCILIATION_SKIPPED). Presenting the credential again answers409 already_paid. Enable push only if you are willing to keep payments for failed handlers and handle them yourself.
mppTempoSession(options) — experimental
Options: realm, secret, rpcUrl, chainId, recipient (payee), token, denomination, escrow (default TIP-20 channel precompile 0x4d50…0000), operator (default none), fetch, clock.
| Capability | Value | Why |
|---|---|---|
| flows | ['upfront'] | Voucher coverage can only be checked in settle, where the authorization's consumption is known; settling before the handler means an uncovered call is refused before it runs |
| authorization | reusable | The authorization is the channel (proofId = channel id) |
| limit | deposit − on-chain settled, at first sight | Ledger capacity |
| refund | true | Nothing is captured per charge; a refund removes the charge from consumption, and the close helper captures consumption only |
| partialRefund, variableAmount | false | Not implemented |
| lookup | true | Settle and refund have no external effect, so lookup is exact: interrupted refunds are complete, interrupted settlements never happened |
- Verification (
action: "voucher"): descriptor payee/token/operator, recomputed v2 channel id, EIP-712Voucher(bytes32 channelId,uint96 cumulativeAmount)under theTIP20 Channel Reservedomain recovered toauthorizedSigneror payer (low-s), and live channel state viagetChannelState: exists, no close requested, voucher ≤ deposit and ≥ settled. - Settlement of a charge accepts the voucher only if
cumulativeAmount ≥ baseline + consumed + reservedof the authorization, wherereservedalready includes this charge and every other in-flight charge. Concurrent calls therefore can never be covered by the same voucher value; replaying a voucher is harmless. The voucher is recorded in the charge'ssettlement.details. - What
settledmeans here: the payee holds a payer-signed voucher covering the charge. It does not mean funds moved. Funds move when you close the channel on-chain withtempoSessionClose({ authorization, charges, settledOnChain? }), which returns{ to, data, captureAmount, cumulativeAmount }forclose(descriptor, cumulativeAmount, captureAmount, signature): it capturesmax(baseline + ledger consumption, settledOnChain)using the highest voucher, and refunds the rest of the deposit to the payer. Submit it from the payee account with your own wallet; the package holds no keys. - Guarantee gap: a payer can
requestClose()andwithdraw()after the escrow's grace period (15 minutes in the reference contract). Anything not captured by then is lost to the merchant, even though the ledger sayssettled. Watch forCloseRequestedand close promptly. Never call the escrow'ssettle()with the highest voucher directly: it captures the full voucher, including refunded or unused value. - Not supported:
open,topUp, andclosecredentials (the payer must open and fund the channel on-chain before sending vouchers), session protocol v1, top-ups raising the ledger limit, and SSE/WebSocket metering. - Why experimental: vouchers pass from
verifytosettlein process memory (a rail cannot write to the authorization after it is opened), and the rail cannot see consumption at verification. Both are safe in the upfront flow as implemented, but they rule out the authorization flow and variable prices. The core change that removes this: pass the stored authorization (or aLedgerReader) intoverifyfor rails withauthorization: 'reusable', and letopenAuthorizationcarry a per-charge proof payload ontoNewCharge(e.g.NewCharge.proof: Json, handed tosettleascharge.proof).
Core change needed
- Sessions (deferred past v0.1):
NewCharge.proof(per-request proof data persisted with the charge and passed tosettle) and read access to the existing authorization inverify, as described above.
Verification status
mppStripe has been verified against Stripe in test mode, including retries, refunds, and reconciliation. The Tempo rails have not been run against a Tempo node: everything below for them was tested against in-process fakes and published vectors.
Rail conformance (railConformance() from tollstile/testing, test/conformance.test.ts):
| Rail | Result |
|---|---|
| mppStripe | all cases pass; redaction skipped (no evidence is stored) |
| mppTempo pull | all cases pass |
| mppTempo push | all run cases pass (a failed handler leaves the charge settled/failed). The settle-fault and tamper cases are skipped: the rail never settles push payments, and a tampered push proof is itself an on-chain transfer the kit would count as a settlement |
| mppTempoSession | not run: experimental, reusable authorization whose settlement is off-chain until close |
- Challenge ids: mppx 0.9.3's HMAC test vectors (
test-vector-secret), JCS: RFC 8785 examples. - Stripe: an in-memory Stripe with idempotency replay/conflict, Search visibility lag, refunds, declines, 5xx, and dropped connections.
- Tempo: transactions built and signed in the tests with
@noble/curveskeys and the package's own RLP encoder, a fake JSON-RPC node foreth_sendRawTransactionSync,eth_getTransactionReceipt, andeth_call. The0x76field layout and sign hash followoxTxEnvelopeTempo; EIP-712 voucher hashing followsoxChannel.getVoucherSignPayload. No bytes from a real Tempo client were used.
To verify live:
- Stripe (test mode): create an SPT with
POST /v1/test_helpers/shared_payment/granted_tokens(payment_method=pm_card_visa, usage limits for the challenged amount, previewStripe-Version), send it as the credential for a $0.50+ route, and confirm asucceededPaymentIntent withmetadata.challenge_id. Check whichsptParameteryour account accepts. Force a handler failure and confirm the refund. Then settle once with a blocked network and runreconcile()aftersearchLagMsto confirm Search finds the PaymentIntent. Runnpx mppx@latest validate <url>against the endpoint. - Tempo charge (Moderato, chain 42431): pay a challenge with the
mppxclient in pull mode and confirm the transaction hash in the receipt on the explorer; check that the client uses the challengememowithtransferWithMemoand does not request fee sponsorship. Repeat with push mode if enabled. ComparedecodeTempoTransactionagainst a transaction serialized byviem/tempo. - Tempo session: open a v2 channel on Moderato with the
mppxsession client, sendvouchercredentials to a priced route, then submittempoSessionClose()calldata from the payee and confirm the capture amount and payer refund on-chain.
MIT © 2026 Paradigm AI Inc.
