Skip to content

proposal: mppx.challenge and mppx.verifyCredential interface refactor#337

Open
brendanjryan wants to merge 3 commits intomainfrom
proposal/ucp-api-improvements
Open

proposal: mppx.challenge and mppx.verifyCredential interface refactor#337
brendanjryan wants to merge 3 commits intomainfrom
proposal/ucp-api-improvements

Conversation

@brendanjryan
Copy link
Copy Markdown
Collaborator

@brendanjryan brendanjryan commented Apr 14, 2026

Two ergonomic changes to public mppx interface

mppx.challenge.{method}.{intent}(opts) — Generate a Challenge object using the same options, defaults, and schema transforms as the 402 handler. Eliminates manual base-unit conversion.

const challenge = mppx.challenge.tempo.charge({
  amount: '25.92',           // human-readable — SDK applies parseUnits
  description: 'Order #123',
})

mppx.verifyCredential(credential) — Single-call end-to-end verification (deserialize → HMAC → method match → schema validate → expiry → verify). Replaces 5 manual steps.

const receipt = await mppx.verifyCredential('eyJjaGFsbGVuZ2...')
const receipt = await mppx.verifyCredential(credential)

Both are extractions of existing createMethodFn internals — no new verification logic.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 14, 2026

Open in StackBlitz

npm i https://pkg.pr.new/mppx@337

commit: de17026

Add two new methods on the Mppx instance:

- mppx.challenge.{method}.{intent}(opts) — generates Challenge objects
  using the same options, defaults, and schema transforms as the 402
  handler. Eliminates manual base-unit conversion for UCP/webhook use.

- mppx.verifyCredential(credential) — single-call end-to-end
  verification: deserialize, HMAC-check, method match, schema validate,
  expiry check, and verify. Replaces 5 manual steps.

Both are extractions of existing createMethodFn internals.

Tests cover charge + session intents, multi-method dispatch, schema
transforms, HMAC rejection, expiry, invalid payloads, unregistered
methods, malformed input, and full round-trip flows.
@brendanjryan brendanjryan changed the title proposal: mppx.challenge and mppx.verifyCredential for UCP proposal: mppx.challenge and mppx.verifyCredential interface refactor Apr 14, 2026
@brendanjryan brendanjryan force-pushed the proposal/ucp-api-improvements branch from 894000e to de17026 Compare April 14, 2026 16:29
@raginpirate
Copy link
Copy Markdown

Thank you so much for iterating on this, the APIs here are perfect for more complex workflows managing the http requests manually ❤️ Quick feedback from Claude while testing locally:


mppx.challenge.tempo.charge() -- async request hook not awaited

What you'd expect to do:

const challenge = mppx.challenge.tempo.charge({
  amount: '25.92',
  description: 'Order #123',
  meta: { checkout_id: 'chk_abc' },
})

Where it breaks: The tempo method's request hook is async (it resolves chainId via getClient). createChallengeFn at line 1022 calls it synchronously:

const request = (parameters.request
  ? parameters.request({ request: merged })  // returns Promise, not awaited
  : merged)

The Promise object gets passed to Challenge.fromMethod()PaymentRequest.fromMethod()schema.request.parse(Promise) → zod fails with invalid_type on every field.

Why the tests pass: Test methods use synchronous request hooks with no async resolution. The async path is never exercised.

Fix: createChallengeFn should return an async function and await the request hook:

return async (options) => {
  // ...
  const request = parameters.request
    ? await parameters.request({ request: merged })
    : merged
  // ...
}

mppx.verifyCredential() -- double schema transform on request

What you'd expect to do:

// challenge was generated with amount '25.92', decimals 6
// challenge.request.amount is '25920000' (post-transform)
const receipt = await mppx.verifyCredential(credential)

Where it breaks: verifyCredential at line 972 passes credential.challenge.request (post-transform, base units) to mi.verify():

const request = credential.challenge.request as z.input<typeof mi.schema.request>
return mi.verify({ credential, request })

But the tempo verify() at tempo/server/Charge.ts:157 calls Methods.charge.schema.request.parse(request), which applies parseUnits again. The challenge request is post-transform (amount: "25920000", no decimals field), but parse() expects pre-transform input (amount: "25.92", decimals: 6). It fails because decimals is missing from the post-transform output.

Even if decimals were present, parseUnits("25920000", 6) would produce 25920000000000 -- a double conversion.

Why the tests pass: The round-trip test at line 839 uses a mock verify() that returns mockReceipt() without calling schema.request.parse(). The real tempo verify() always parses.

Fix: verifyCredential should signal to verify() that the request is already transformed, or restructure so the schema parse is skipped. One approach: have verifyCredential call the verification logic with the resolved request directly, bypassing the parse() that verify() applies. The HMAC already proves the request params are authentic -- re-parsing them is unnecessary.


Summary: Both APIs have the right shape and would collapse our 25-line manual verification into 1 line. The bugs are in the boundary between the new code and the existing tempo method internals (async hooks, schema transforms). Our demo stays on the manual path for now and we'll adopt when these are fixed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants