Trace the payments your AI agents make with the Machine Payments Protocol (MPP) and its TypeScript SDK, mppx, with OpenTelemetry.
With MPP, an API answers 402 Payment Required with a challenge, and the agent pays it on Tempo with a TIP-20 token
transfer that it either sends itself (push) or signs for the server to broadcast (pull). Each payment becomes a
payment {chainId} span inside your agent’s trace, with what was paid, to whom, in which mode and whether it settled,
and, with a reader, a linked confirm {chainId} span with block, gas and fees, and whether the transaction carries
this payment.
Install
Section titled “Install”npm install @hashspan/mpp @hashspan/core @hashspan/viem mppx @opentelemetry/api viem@hashspan/core and @hashspan/viem are needed for the shared tracker of the example below. Requires Node.js 22.3 or
later, mppx 0.13.5 or a later 0.13 release, and viem 2.57.1 or later (mppx’s own floor). Bring your own
OpenTelemetry SDK and exporter.
import { createTxTracker } from '@hashspan/core';import { withHashspan } from '@hashspan/mpp';import { withHashspan as withViemHashspan } from '@hashspan/viem';import { Mppx, tempo } from 'mppx/client';import { createClient, createPublicClient, http, publicActions, walletActions } from 'viem';import { privateKeyToAccount } from 'viem/accounts';import { tempo as tempoChain } from 'viem/chains';
// One tracker for both adapters, so that a payment span links to the payer's own send span and each// transaction gets one confirm span.const tracker = createTxTracker();const account = privateKeyToAccount(privateKey);const wallet = createClient({ account, chain: tempoChain, transport: http() }) .extend(publicActions) .extend(walletActions) .extend(withViemHashspan({ tracker }));
const mppx = Mppx.create({ methods: [tempo({ account, getClient: () => wallet })], polyfill: false,});// Registers observers on the client; call it once per client.const hashspan = withHashspan(mppx, { tracker, reader: createPublicClient({ chain: tempoChain, transport: http() }),});
await mppx.fetch('https://api.example.com/weather'); // payment span, then confirm span
// Before a short-lived process exits:await hashspan.flush();A test runs this example as written, against an mppx server and Anvil in Tempo mode, with only its URLs redirected
(quick-start.int.test.ts).
Pass the client Mppx.create() returns. Every payment made through its fetch, or through the global fetch it
polyfills (the default), reaches the adapter. The adapter only registers observers with on(): it replaces no
method, and its observers never throw, never return a value and never change a payment.
Push and pull
Section titled “Push and pull”With a private key, mppx pays in pull mode: it signs the transaction, and the server broadcasts it, so there is no
send span, and the confirm span comes from the reader. With mode: 'push', or a wallet (JSON-RPC) account, the payer
sends the transaction itself, through the client getClient returns: extend that client with @hashspan/viem’s
withHashspan(), as in the example, to record the send span. Give both adapters the same tracker: the payment span
then starts with a link to that send span, and the transaction gets one confirm span. With two trackers, a push payment
gets two confirm spans and two fee samples for one transaction, and no link between the payment and its send.
Options
Section titled “Options”withHashspan(mppx, options) accepts the @hashspan/core options
(address mode, agent identity, redaction hook, …), decodeRevertReason as in @hashspan/viem but off by default,
maxBackgroundConfirmations as in @hashspan/viem (it limits the confirmations through the reader; a payment whose
confirmation is not started gets no verified), tracker, reader (a viem public client, or a function returning
one for a chain id), confirmTimeoutMs (default 120 000 ms) and observeFailures (default false, see
failures). Without a reader, only payment spans are recorded,
with the settlement as the server reported it. With tracker, the core options are not used; it must come from
@hashspan/core 1.2 or later, which records who sent the settlement, and with an older one nothing is traced, with
a diag warning. Options are read once, from the object’s own enumerable properties: options it inherits through a
prototype, such as the getters of a class instance, are ignored
(ADR 0025).
Verified settlements
Section titled “Verified settlements”The server’s Payment-Receipt names the settlement’s transaction (reference). For push, and for pull without a
fee payer, the payer knows the transaction’s hash before it is sent: the payment span records that hash from its
start, and a server that reports another one does not replace it. With a reader, the payment span records
blockchain.payment.verified: true when the transaction’s receipt carries, from the payer’s address, the transfer
of the amount to the recipient with the memo that binds it to this challenge (mppx’s attribution memo: its tag, the
server’s realm and the challenge id), and one transfer per split; false when it does not, or when the server
reported a transaction other than the payer’s own. All of it is checked from the challenge the payer signed for and
the payer’s own credential, not from the server’s receipt. For a sponsored pull, whose hash only the server’s
reference names, true also needs the mined transaction to be the one the payer signed: the reader fetches it once
(eth_getTransactionByHash, at most 10 s, off your call’s path), and its sender must be the payer and its nonceKey,
nonce, validBefore and validAfter those of the payer’s signed transaction. The attribute is absent when no
check was possible, for example: no reader, no receipt, a reverted one, a reader on another chain, a receipt funded in
another currency (fundingCurrency), values that do not validate, or, for a sponsored pull, a credential whose
transaction does not decode, a transaction that cannot be read or one whose sender or nonce fields differ. With a
reader, the payment span is exported once the receipt is checked; its end time stays when the response came.
Shutting down
Section titled “Shutting down”flush({ timeoutMs }) (default 10 000 ms) waits for paid payments still waiting for an outcome, then for
confirmations through the reader, and ends what is left as timeout (a payment already settled and waiting for its
receipt check ends as settled, without verified). It does not wait for a challenge that was never paid (the client’s
policy refused it, or signing failed): that challenge records nothing and is dropped at its deadline. Call it before
a short-lived process exits.
Recorded
Section titled “Recorded”| What the client sees | Payment span |
|---|---|
A response with a readable Payment-Receipt |
blockchain.payment.status settled and the transaction hash: the payer’s own, or for a sponsored pull the receipt’s reference |
A response without a Payment-Receipt, or with one that cannot be read |
error status, error.type no_settlement or _OTHER |
| A 5xx answer, or a paid request that failed on the network | by default, as for no outcome below; with a reader, the confirm span of the payer’s hash records the transaction. With observeFailures: pending and the hash, the confirm span resolves it, or for a sponsored pull, whose hash only the receipt names, error status with error.type PaymentOutcomeUnknownError |
| Creating the payment failed: the client’s policy or spending limits, signing, or a push transaction that failed | by default, nothing. With observeFailures: error status, the error’s class name as error.type, no hash |
| A challenge the client refused before paying (no method for it, an expired one) | nothing |
| A zero-amount challenge, answered with a signed proof | nothing, failures included: no value moves |
| No outcome: a 3xx, a 4xx, or a final 402 after payment | error status, error.type timeout, once the challenge’s expires (or, without one, mppx’s default validity of 25 s) plus 30 s passed (at most 1 h), when more than 1000 payments are open (the oldest first), or on flush(); the payer’s own hash stays recorded |
Every payment span records the payer and the chain (from the credential the payer’s client wrote), the recipient,
the asset (the challenge’s currency), the amount, mpp.method (tempo), mpp.intent (charge), mpp.mode
(push or pull), mpp.challenge.id (the id the server chose, which joins your trace to the server’s logs and to
the transfer’s memo; on spans only) and the origin of the resource URL as mpp.resource, e.g.
https://api.example.com: paths of paid APIs often carry user or account identifiers. The core option
paymentResource: 'path' records the path too (never the query string, fragment or user info), and 'off' nothing;
see docs/semconv.md.
The settlement’s fee is the payer’s: its fee sample carries no blockchain.fee.payer, or sponsor when the server
paid it as fee payer, never facilitator. Count only settled payments as paid; a pending one is paid once its
confirm span records the receipt. What a tool call or task spent is the amount of each settled payment, per
blockchain.payment.asset: amounts of different assets do not add up. The payment span records the payment, not the
paid request’s status or your tool’s outcome; spans are not an accounting record: a trace that a sampler dropped, or
that was not exported, misses its payments.
Failures
Section titled “Failures”By default, the adapter does not observe mppx’s payment.failed event: observing it makes mppx buffer the body of a
5xx answer to a paid request (at most 64 KiB and 2 seconds), so with an observer a longer body reaches you cut, with an
Mppx-Body-Truncated: true header, and a body whose stream fails makes fetch reject. Without it, the adapter changes
nothing you get. A paid request answered with a 5xx, or that failed on the network, then ends as timeout once the
challenge’s deadline passed, with the payer’s own hash; with a reader, the confirm span then records the transaction.
A payment that failed before its credential (the client’s policy or spending limits, signing, a failed push send)
records nothing.
observeFailures: true adds the payment.failed observer, with mppx’s buffering: such payments then end at once,
as pending with the hash, or as an error.
Try it locally
Section titled “Try it locally”A payment can be made and traced on a local Anvil chain in Tempo mode (anvil --network tempo), with no public network and no funds: Anvil funds its test accounts with pathUSD. The
charge integration test
is the complete recipe, with its helpers in
tempo-anvil.ts: a vitest file
that uses the repository’s test helpers, to read rather than to copy as is. Take from it:
- The chain: Anvil started with
--network tempoand Tempo’s chain id (4217), with one block mined before the first charge (a fresh chain’s first block fails Tempo’svalidAftercheck), and viem’stempochain with Anvil’s URL. - The paid API,
paidApi(): an mppx server (Mppx.createfrommppx/server, withtempo({ recipient, currency, account, getClient })) whosecharge({ amount })answers each request, served as a function that stands in forfetch, so no port is opened. WithfeePayer: true, the server sponsors the fee of pull charges. - The paying client: as in usage, with a reader on Anvil.
Known limits
Section titled “Known limits”- Only Tempo
chargepayments are traced, push and pull. Sessions (payment channels and their vouchers), subscriptions, theevmandstripemethods and x402 over mppx are made untraced, with adiagwarning once per method and intent. Only the paying client is traced, not an mppx server. - Paid MCP tools are not traced:
McpClient.wrapfrommppx/mcp/clientpays with the methods it is given, and fires none of the client’s events. - Payments made with the client’s manual APIs (
preparePayment,prepareRequest,createCredential) fire no outcome event: their payment spans end astimeout. - A 3xx, a 4xx or a final 402 after payment fires no event: the payment ends as
timeout, although the money may have moved; for a push payment, its send span and hash show it. - A sponsored pull’s hash, and with it the confirm span, rest on the server’s
reference. Its verdict istrueonly when the mined transaction is the one the payer signed (its sender and nonce fields), so it costs one more request to the reader; when that cannot be shown, the payment has no verdict. - Auto-swap and machine-token routes, whose receipt names another
fundingCurrency, get no verdict. - A push payment from a wallet (JSON-RPC) account is sent with
sendCallsSync: the payment span links to the call batch’s send span, as checked on Anvil, where viem falls back toeth_sendTransactionfor a single call; a wallet that carries the batch otherwise (several transactions, a user operation) was not checked. - By default, a 5xx after payment, or a paid request that failed on the network, ends as
timeoutonly once the challenge’s deadline passed, and a payment that failed before its credential records nothing (failures).observeFailures: truerecords them at once, but lets mppx buffer and cut 5xx bodies. - The adapter cancels its copy of the paid response once the
payment.responseobservers ran, unless one of them started reading it: an observer of yours that waits longer than that before it reads the copy finds it cancelled. - The limits of
@hashspan/viem’s confirmations and of the core apply to the confirm span (@hashspan/viem,@hashspan/core).
Compatibility
Section titled “Compatibility”This package is 0.x and follows mppx, which is 0.x too: it supports one mppx minor line at a time (see install), and a new line raises that range in a minor release of this package. The design is in ADR 0029.
License
Section titled “License”Apache-2.0