Skip to content

Auto-derive the email x402 challenge from the inbound payment request in pay-email#272

Merged
etbyrd merged 5 commits into
mainfrom
x402-pay-email-derive-challenge-from-inbound
Jun 27, 2026
Merged

Auto-derive the email x402 challenge from the inbound payment request in pay-email#272
etbyrd merged 5 commits into
mainfrom
x402-pay-email-derive-challenge-from-inbound

Conversation

@etbyrd

@etbyrd etbyrd commented Jun 27, 2026

Copy link
Copy Markdown
Member

Problem

A real payer never has the payee-side create-email-challenge response that payments pay-email / pay-email-step expected via --challenge/--challenge-file. They receive the challenge as an interaction.json attachment on the inbound payment-request email, which is the strict snake-case wire envelope shape with a different field layout (step, step_id, expires_at, nested payload.payment_requirements / payload.challenge_nonce). With only the attachment, a payer could not produce --challenge without reverse-engineering the reshape one error at a time, making the command effectively unusable for the actual payer journey.

Fix

payments pay-email --in-reply-to <inbound-id> (with PRIMITIVE_X402_PRIVATE_KEY set) now works end to end with no --challenge-file and no manual reshape:

  • It downloads the inbound email's interaction.json attachment (the only download surface is the gzipped tar at /emails/{id}/attachments.tar.gz; we gunzip with the built-in node:zlib and read the tar with a small dependency-free ustar reader, mirroring the zone-file download's direct-fetch + bearer pattern) and reshapes the wire envelope into the challenge object the signer consumes.
  • The reshape reuses the SDK's canonical, signed-path-tested parseEmailChallengeFromPart, so the mapping is not re-implemented or guessed. Field-by-field:
    • nonce_binding.interaction_idinteraction_id
    • nonce_binding.challenge_step_idstep_id
    • nonce_binding.challenge_noncepayload.challenge_nonce
    • challenge.payment_requirementspayload.payment_requirements
    • challenge.expires_atexpires_at
    • interaction_idinteraction_id
  • --challenge / --challenge-file remain as an explicit override (back-compat): when given, used as-is; when absent, derived from --in-reply-to.

New command

payments challenge-from-email --id <inbound-id> prints the correctly-shaped challenge JSON, so SDK and pay-email-step users can get it without spelunking (pipeable into pay-email-step).

Settlement clarity

--wait only confirms email delivery (SMTP 250), not on-chain settlement. Added --wait-settle (with --settle-timeout / --settle-interval): after sending, it polls the inbox for the follow-up x402 settlement interaction email (matched on the confirmed interaction_id) and prints the receipt, surfacing settle_tx when present. When not used, output and help state plainly that settlement is async and where settle_tx appears.

Help

pay-email / pay-email-step help now defines the challenge object's fields precisely, states it differs from the inbound attachment, notes --in-reply-to alone derives it, and includes a worked example (receive request → pay-email --in-reply-to <id> → settlement receipt).

Tests

  • Tar/gzip attachment extraction against real archive bytes.
  • The reshape maps every field correctly.
  • pay-email with ONLY --in-reply-to (no --challenge-file) derives the challenge from a mocked inbound attachment and produces the same signed authorization and locked normative nonce as passing the equivalent --challenge object.
  • challenge-from-email emits the right shape; --challenge override skips the download.
  • Settlement poll matches by interaction_id and surfaces settle_tx; timeout path exits non-zero with an async-settlement message.
  • The network boundary is mocked in all cases.

Verification

  • make check exits 0; the locked normative nonce vector 0xc955...16c6e is byte-identical across the Node, Python, and Go SDKs (shared-fixtures suites green).
  • pnpm lint and typecheck clean; CLI builds with the SDK inlined (bundle-isolation check OK).
  • Version bumped to 1.14.1 across the four version files plus uv.lock; the ^1.14.0 scaffold ranges already cover it (lockstep test green).

… in pay-email

A real payer never has the payee-side create-email-challenge response that
pay-email / pay-email-step expected as --challenge. They receive the challenge
as an interaction.json attachment on the inbound payment-request email, in the
wire-envelope shape (different field layout). Previously they had to reverse
engineer the reshape by hand, which made the command effectively unusable.

pay-email --in-reply-to <id> now downloads that inbound email's interaction.json
attachment and reshapes the wire envelope into the challenge object the signer
consumes, via the SDK's canonical parseEmailChallengeFromPart, so the whole
payer journey is one command. The mapping is the SDK's signed-path-tested one
(nonce_binding.interaction_id <- interaction_id, challenge_step_id <- step_id,
challenge_nonce <- payload.challenge_nonce, payment_requirements <-
payload.payment_requirements, expires_at <- expires_at). --challenge /
--challenge-file remain as an explicit override.

Add payments challenge-from-email --id <id> to print the correctly-shaped
challenge for SDK / pay-email-step users. Add --wait-settle to pay-email to
poll for the follow-up x402 settlement interaction email and surface settle_tx,
and make help and output state plainly that settlement is async and where
settle_tx appears. Rewrite the pay-email help to define the challenge fields,
note they differ from the inbound attachment, and add a worked example.

Tests cover the tar/gzip attachment extraction, the field-by-field reshape, that
pay-email with only --in-reply-to derives the challenge and produces the same
signed authorization (and locked normative nonce) as the equivalent --challenge,
that the override skips the download, and the settlement poll.

Bump to 1.14.1.
@greptile-apps

greptile-apps Bot commented Jun 27, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
cli-node/src/oclif/commands/payments-pay-email.ts Adds default challenge derivation from --in-reply-to and optional settlement polling.
cli-node/src/oclif/commands/payments-email-challenge.ts Adds attachment archive download, tar extraction, and SDK-backed challenge reshaping.
cli-node/src/oclif/commands/payments-settlement.ts Adds inbox polling for settlement receipt emails with retry and pagination handling.
cli-node/src/oclif/commands/payments-challenge-from-email.ts Adds a command that prints a signable challenge object from an inbound email.
cli-node/src/oclif/index.ts Registers the new payments:challenge-from-email command.

Reviews (5): Last reviewed commit: "Locate the inbound challenge attachment ..." | Re-trigger Greptile

Comment thread cli-node/src/oclif/commands/payments-pay-email.ts Outdated
Comment thread cli-node/src/oclif/commands/payments-settlement.ts
…t attachment fetches

- pay-email auto-derive no longer keys on process.stdin.isTTY. In CI / Docker /
  any non-interactive process stdin is not a TTY even when nothing is piped, so
  the TTY check skipped derivation there and the one-command flow could hang on
  or mis-parse stdin. Derive whenever no --challenge / --challenge-file is given;
  pay-email-step keeps its stdin piping.
- The settlement poll now records an email as checked only AFTER its attachment
  archive is successfully fetched. A settlement email can be searchable before
  its archive is ready, and a single fetch can fail transiently; marking it
  checked first made that a permanent skip and could time out --wait-settle even
  though the receipt arrived in time.

Adds regression tests: derivation runs with isTTY=false, and a transient 404 on
the settlement attachment is retried rather than permanently skipped.
Comment thread cli-node/src/oclif/commands/payments-settlement.ts Outdated
…ages

The settlement poll read only the first 50 oldest results for the fixed since
window and never followed the cursor. If more than 50 attachment-bearing emails
from the payee arrived after the send before the receipt, each poll re-read the
same first page while the receipt sat on a later page, so --wait-settle could
time out even though the settlement email had arrived. The poll now drains every
page (following the cursor) each cycle, re-scanning from since so a transiently
skipped email is still retried and the checked set prevents re-fetching.

Adds a test where the receipt lands on page 2 and is found by following the
cursor, and resets the searchEmails mock between settlement cases.
Comment thread cli-node/src/oclif/commands/payments-settlement.ts Outdated
etbyrd added 2 commits June 26, 2026 23:51
… receipt is parseable

A null part (archive present but interaction.json not written yet) or an
unparseable part (incomplete/partial JSON) is a transient not-ready state, but
the poll was marking the row checked as soon as the archive fetch succeeded, so
a receipt that became readable on a later poll would be skipped forever and
--wait-settle could still time out. Move checked.add(row.id) to after a non-null,
parseable envelope is in hand. Adds a test where the first poll sees an
unreadable interaction.json and a later poll sees the full receipt.
…t a bare filename

The attachments archive names each entry <part_index>_<filename>, so the
challenge member is 0_interaction.json, not interaction.json. The auto-derive
matched the bare filename and failed on a real payment request with "has no
interaction.json attachment". Resolve the member from the email's attachment
metadata (filename/content_type -> tar_path) and extract that exact entry, with
a fallback that strips the leading <digits>_ part-index prefix when metadata is
unavailable (the settlement receipt poll has no metadata). Tests now use the
real 0_interaction.json naming.
@etbyrd etbyrd added this pull request to the merge queue Jun 27, 2026
Merged via the queue into main with commit d586694 Jun 27, 2026
17 checks passed
@etbyrd etbyrd deleted the x402-pay-email-derive-challenge-from-inbound branch June 27, 2026 07:43
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.

1 participant