Auto-derive the email x402 challenge from the inbound payment request in pay-email#272
Merged
Merged
Conversation
… 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.
Confidence Score: 5/5This looks safe to merge.
|
| 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
…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.
…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.
… 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A real payer never has the payee-side
create-email-challengeresponse thatpayments pay-email/pay-email-stepexpected via--challenge/--challenge-file. They receive the challenge as aninteraction.jsonattachment 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, nestedpayload.payment_requirements/payload.challenge_nonce). With only the attachment, a payer could not produce--challengewithout 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>(withPRIMITIVE_X402_PRIVATE_KEYset) now works end to end with no--challenge-fileand no manual reshape:interaction.jsonattachment (the only download surface is the gzipped tar at/emails/{id}/attachments.tar.gz; we gunzip with the built-innode:zliband 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.parseEmailChallengeFromPart, so the mapping is not re-implemented or guessed. Field-by-field:nonce_binding.interaction_id←interaction_idnonce_binding.challenge_step_id←step_idnonce_binding.challenge_nonce←payload.challenge_noncechallenge.payment_requirements←payload.payment_requirementschallenge.expires_at←expires_atinteraction_id←interaction_id--challenge/--challenge-fileremain 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 andpay-email-stepusers can get it without spelunking (pipeable intopay-email-step).Settlement clarity
--waitonly 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 confirmedinteraction_id) and prints the receipt, surfacingsettle_txwhen present. When not used, output and help state plainly that settlement is async and wheresettle_txappears.Help
pay-email/pay-email-stephelp now defines the challenge object's fields precisely, states it differs from the inbound attachment, notes--in-reply-toalone derives it, and includes a worked example (receive request →pay-email --in-reply-to <id>→ settlement receipt).Tests
pay-emailwith 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--challengeobject.challenge-from-emailemits the right shape;--challengeoverride skips the download.interaction_idand surfacessettle_tx; timeout path exits non-zero with an async-settlement message.Verification
make checkexits 0; the locked normative nonce vector0xc955...16c6eis byte-identical across the Node, Python, and Go SDKs (shared-fixtures suites green).pnpm lintand typecheck clean; CLI builds with the SDK inlined (bundle-isolation check OK).uv.lock; the^1.14.0scaffold ranges already cover it (lockstep test green).