From c23cae1a39564126143b0a521393a93cfeeba9de Mon Sep 17 00:00:00 2001 From: Ethan Byrd Date: Fri, 26 Jun 2026 23:32:34 -0700 Subject: [PATCH 1/5] Auto-derive the email x402 challenge from the inbound payment request 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 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 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. --- cli-node/package.json | 2 +- .../commands/payments-challenge-from-email.ts | 108 +++++ .../commands/payments-email-challenge.ts | Bin 0 -> 9209 bytes .../src/oclif/commands/payments-pay-email.ts | 277 ++++++++++--- .../src/oclif/commands/payments-settlement.ts | 175 ++++++++ cli-node/src/oclif/index.ts | 7 + .../oclif/payments-email-challenge.test.ts | 230 +++++++++++ .../tests/oclif/payments-pay-email.test.ts | 380 +++++++++++++++++- .../tests/oclif/payments-settlement.test.ts | 198 +++++++++ sdk-go/VERSION | 2 +- sdk-node/package.json | 2 +- sdk-python/pyproject.toml | 2 +- sdk-python/uv.lock | 2 +- 13 files changed, 1318 insertions(+), 67 deletions(-) create mode 100644 cli-node/src/oclif/commands/payments-challenge-from-email.ts create mode 100644 cli-node/src/oclif/commands/payments-email-challenge.ts create mode 100644 cli-node/src/oclif/commands/payments-settlement.ts create mode 100644 cli-node/tests/oclif/payments-email-challenge.test.ts create mode 100644 cli-node/tests/oclif/payments-settlement.test.ts diff --git a/cli-node/package.json b/cli-node/package.json index 9d64dcc..1f22d1f 100644 --- a/cli-node/package.json +++ b/cli-node/package.json @@ -1,6 +1,6 @@ { "name": "@primitivedotdev/cli", - "version": "1.14.0", + "version": "1.14.1", "description": "Official Primitive CLI: deploy Primitive Functions, send and inspect mail, manage endpoints, all from the terminal. Wraps the @primitivedotdev/sdk runtime client with one-shot commands.", "type": "module", "sideEffects": false, diff --git a/cli-node/src/oclif/commands/payments-challenge-from-email.ts b/cli-node/src/oclif/commands/payments-challenge-from-email.ts new file mode 100644 index 0000000..0244702 --- /dev/null +++ b/cli-node/src/oclif/commands/payments-challenge-from-email.ts @@ -0,0 +1,108 @@ +import { Command, Flags } from "@oclif/core"; +import { createAuthenticatedCliApiClient } from "../api-client.js"; +import { + API_BASE_URL_FLAG_DESCRIPTION, + runWithTiming, + TIME_FLAG_DESCRIPTION, +} from "../api-command.js"; +import { deriveEmailChallengeFromInbound } from "./payments-email-challenge.js"; +import { reportX402Error } from "./payments-shared.js"; + +// `primitive payments challenge-from-email` prints the correctly-shaped email +// challenge object derived from an inbound payment-request email's +// `interaction.json` attachment. +// +// Why this exists: `pay-email` / `pay-email-step` consume a CHALLENGE OBJECT in +// the shape the payee's `create-email-challenge` API returns, but a real payer +// only ever has the inbound email's `interaction.json` attachment, which is the +// WIRE ENVELOPE shape with a different field layout. `pay-email --in-reply-to` +// now auto-derives the challenge, but SDK callers and anyone driving +// `pay-email-step` (sign-only, portable artifact) still need a way to GET the +// reshaped challenge without hand-mapping the envelope. This command does that +// reshape (via the SDK's canonical parseEmailChallengeFromPart) and prints the +// result, so it can be piped: +// +// primitive payments challenge-from-email --id \ +// | primitive payments pay-email-step > interaction.json + +class PaymentsChallengeFromEmailCommand extends Command { + static description = + `Derive and print the email x402 challenge object from a received payment-request email. + + A payer receives an x402 payment request as an email carrying an + \`interaction.json\` attachment (the wire envelope). The signing commands, + however, take the challenge object the payee's \`create-email-challenge\` API + returns, which has a different field layout. This command downloads the inbound + email's \`interaction.json\` attachment and reshapes it into that challenge + object, printing it to stdout. + + Use it when you want the challenge as a portable artifact (e.g. to feed + \`pay-email-step\` for offline signing, or an SDK \`payEmailChallenge\` call). + For the simple one-shot, prefer \`pay-email --in-reply-to \`, which derives + the challenge internally and needs no intermediate file. + + primitive payments challenge-from-email --id > challenge.json + primitive payments challenge-from-email --id \\ + | primitive payments pay-email-step > interaction.json`; + + static summary = + "Print the email x402 challenge object derived from a received payment-request email"; + + static examples = [ + "<%= config.bin %> payments challenge-from-email --id ", + "<%= config.bin %> payments challenge-from-email --id | <%= config.bin %> payments pay-email-step > interaction.json", + ]; + + static flags = { + "api-key": Flags.string({ + description: + "Primitive API key override (defaults to PRIMITIVE_API_KEY or saved OAuth login credentials)", + env: "PRIMITIVE_API_KEY", + }), + "api-base-url": Flags.string({ + description: API_BASE_URL_FLAG_DESCRIPTION, + env: "PRIMITIVE_API_BASE_URL", + hidden: true, + }), + id: Flags.string({ + description: + "Id of the inbound payment-request email whose interaction.json attachment carries the challenge.", + required: true, + }), + time: Flags.boolean({ + description: TIME_FLAG_DESCRIPTION, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PaymentsChallengeFromEmailCommand); + + const { auth, baseUrlOverridden, requestConfig } = + await createAuthenticatedCliApiClient({ + apiKey: flags["api-key"], + apiBaseUrl: flags["api-base-url"], + configDir: this.config.configDir, + }); + + await runWithTiming(flags.time, async () => { + try { + const challenge = await deriveEmailChallengeFromInbound({ + baseUrl: auth.apiBaseUrl, + emailId: flags.id, + apiKey: auth.apiKey, + headers: requestConfig.headers, + }); + this.log(JSON.stringify(challenge, null, 2)); + } catch (error) { + reportX402Error(error, { + auth, + baseUrlOverridden, + configDir: this.config.configDir, + }); + process.exitCode = 1; + } + }); + } +} + +export default PaymentsChallengeFromEmailCommand; diff --git a/cli-node/src/oclif/commands/payments-email-challenge.ts b/cli-node/src/oclif/commands/payments-email-challenge.ts new file mode 100644 index 0000000000000000000000000000000000000000..f82ebfa8b476a0e65a8b69ff413c3686c6a2c1b1 GIT binary patch literal 9209 zcmd5?ZCBey7T(YK6?Zu~#ZGLQWRspA6GDMFEiEA&aN6wI?!jYeY^%tUcO>CB)c<{- zduK+H4YX&!Eg!&0Gk5Omb6>{se36-g?r2)3U*pBqDh+8(6O+wpC(R<=|C+?(o&6`V zTla)$p-ip^a}_5iGnFJdo$B{kf1ym#!RunR(DeD$-pkD;ohP2`_X`ux<08J*QC39y zwwFgYy)W2+W1jSS^iG@jHcqEh%rt2}r<*EXPP~Xhe<&%iveJg;WnNGoPty+Ng;DWz zR#*u@fC1?bdCN`sj)?9^C>GbI@HWifc3m(h@%%K8z*{>1#wP!mfY&R zPa{VpXKG!cFruD3w*PhWLN7+b4k;#e^5fCj+2H(#0ew9FX>c;6e5MvUq9nf2BI3o- zKL>-|oJOJ1s?d&1CFTgs<%=xMb>A^ZbjO7GCr%4(R9M7WdL2g{|G3)Rua~mnME+}* zKQ@|mkyl%Y(ku=2^*Bx=C?6ZQiPxIHW*Pas#v|EH>jpjjWr0)jYX!G(?E2ShC&_VH zz={w%A=bhrrTP{Iov9p}QAH(rU=1SHz8stkPJbC(iS8;vN#%TqN@>*4N$`7~r6W2T z4v$WL{4h8lQkKfTKXr)?Bbot+KNC3*$lT97gQZi%MAyIYifDaQh34m1P`#A7u!S)N2_P36lFT6mIA))PnfNP`EZ|WA#?q)eE@K=q>UJwbjcA#b$wrFIQpC;4*=aWiQM@)c90OBb6#g)y zmZXY8g)`B{c!dyfXr8D75i*zfVI8I!a8?`Sc?HhWX@U>+PK06sTu$N?DrC*c*-^Gk z8DA(b%|wNokHBsZyi=)fo z_4`wlAx9qubU-@|Czc#3r59K@hSi}YsJEeuu2iQ zS)G3}NeeaN7>N`g`;uPMDVL2x=oE>9GcG>@blQ(7VF(dZlm^ND>7Cmf7`;ef@mBA* z-qT)(c6QopZ_KB6It@|0e!4t8$xyhaFbf}Box%rVc|%5T@BJrhF~$>mMjzrdo^xsP z@#^$5wNPa$(-se6;Mb;4D@pJg-VFw)Wui=X62ouV4OS@W#BPWtrsiD#SE%+jixG8D zb8I-wO|cTHkzVK&!5OZQrb6W*Vo{EhIG@3J5ot6=C5s%v6uDMffe4VL#U=xLV-)1w z7{#iM<#49M8#dWE22zwM97KnlnoJ1@fKTDbye-bEm?FEdH+D901#&}69A1hhppNnt zK87_pG?aMhQSpE`R}Kk5u*C*r9_HBPb7q)v2@+rluNvZ5S0!=mb9s(Zl&Qad@v_T` z!vSGtB0$H0l`TpJFR*OAWF|&odXp^SDj|Z-33Ywa%HyxP2U=}H4(5W>5gVcc;Aqid zDGdf|@2fT$keUsg1G&wOmjkx3$Yo!8^Rh}<6O&vQv`4KMdwbT_ZL}jexAFGH%U5{+ zk_n18bSMi8m0%&40AfOJo}k}pbJy!{skOVeONhMP-|+WfL)~rGBK>gw36&8%fn5&e zh;(7QXS>$8Oj_I*jf&Be_{+%ErV4ck!+io}A%`iUp$${y^n(bD#k#ir*{2MY4CU~m z7=giuKzh)~ZvlYlejgEs7e~v6vY^|uT^q4b z)?$$Wzp2Q?90(Y$;t|o-=8#Wlv!>6XXwM)0K`Dd%f&4)mOAdrt@+$!}ylXLl6n(5= z(C>g!IxS}Y{~GTOtYn@>p2P@-14KUSEXd2TVue|1-12f}eD0{VSy;;?gvBn|jVdtG zUp`J+d+vQap_aTqIH2x}HpH*jTDB}52fL|W(h$0MhrmHxwAw)=pIVlB#nN7fDYfO0 zwWtA&5wzEG#&}NFP;V+}>|t#&r5D)h`T>b86-122g1Pc#Pw`3EauKJv(wj!Fw zNfzF)8PPjK2hOa5q^%YQWt*UWft&;AX0P3*F^qU4!gvNbtltatb0~TX*FqxBI|Q@| zVEK8g=I;voY`jR^BZ-QaH2`uSh&)kT+`$9)xLZ}gpoGyA5o-HMj#Ci;68~PNH_kUn zMYjsDnS*-)MiCWfpnyk#1TamqOv+GDV}Y#}Z2SmN!C5N74(VLH6xBIepi|_}>RpGv zYg6BbRizO%(JUQ|=Jv(;d!)}o%f2>!^Nj_=x6mma+Rs}w7){u(fF}p?03VdMaY{&bj z;D|>LOr?-PCDr%Ej*?q!0ctZp6fG~#2cL%x1d#xB2m)Rj9#pY~uy$Ov8->o^b64qD zq1J@j4<+wChWB>pk3R&Rtv*?ZzikR}Y0DJ>w}38?6oBg(s3CkAA*)ofZ_fy`Ia*Pl zL~IVB=i-H;6H$dE5SuoDqDc0k$#S?Ol7@9%jL|moiSz%H!c^B!i`J0x@m9dadVKd*bXCS8vZ z6im+=hj*>pKKL8r_#9!$+6vNUyN=It+eqJV3;qZfZWG{Dj)9$t!!51V_2p$gIfL@D40cW@XQFHrou)52!=}TgQ2r>DffmMx7!Vg_W^x zlZq8n3gUPd_AK}={``;Guh?L&5wMg%;U;5Lko(^ z)HZZTuE8`A2DTa{Dcrf!MQ++`=Gp;4P*m)8{^JM%%h{*u_(}lgqEByT=FcS(&yd8R zjHURb8TaOK!GheY&9Ji?Ape9}DG&0Gtl(UxxvsfccS)UAmu zce@kt_h>WR#_`fw+U-)7TW6|zPBwb8EqpI?fbAX_?Lf0p@}}Uz-kreih;azp>oKoH zX=ApW%r@8|N_DQ3JpifTYh?fEL_D=y!gST<1J_*)bXf@2<1wV+}(haT7y*^e6Y z3U-jKc?CaGi}+`~I`mBeKeA|;B`Xbx_gd$Z*trfWi)PorEY=ONBh`cH0R_!-*Xb@XW@Mb$Z~0 zBF8u-w0WMww^IDO656WHKEia~({L(b#_rZ`CogI&v5G{Qc(*y5#P!>;cr+pTS`O?| zF-|jAiLRwn1UNUua9aKKSi$-GAz5&17jw(8Ll~JCR^gZ zYDohQKdU^V(FxB;WtLfmRzsySKHE$Ql_*Y`>puNB99}>~r%nbUYq0>mUQr?l~-2! z0fh>{U8u=4l(Uw*b;CW}M)k9m1FQ=S?tP=-)P8T^|1A@IzW3dVW-5>hrh+n?2Odvv zH8jdT4~mb0o70Fh?mgu4uJZKm(Ryp$sFKj2-~tx6I?k%DB6h)zsF1H|pgYCQD8jJz z|6cPyTgL$2amx+{5X+mv!V!q_!c8u-(u6wL20=73LmNnSW$@QGwi2{J6TX*n14FsK z0jh#v&8`dA`}cGLz9ph!pNWXlr4kYgODOUH$daq4KwKhjgc^qi{M!|03@nd*KSw59 Ixgha>0mNd;mH+?% literal 0 HcmV?d00001 diff --git a/cli-node/src/oclif/commands/payments-pay-email.ts b/cli-node/src/oclif/commands/payments-pay-email.ts index 32de591..f0ba80d 100644 --- a/cli-node/src/oclif/commands/payments-pay-email.ts +++ b/cli-node/src/oclif/commands/payments-pay-email.ts @@ -1,7 +1,10 @@ import { Command, Flags } from "@oclif/core"; import type { EmailDetail, SendMailResult } from "@primitivedotdev/api-core"; import { getEmail, sendEmail } from "@primitivedotdev/api-core"; -import type { BuiltPaymentStep } from "@primitivedotdev/sdk/x402"; +import type { + BuiltPaymentStep, + X402EmailChallenge, +} from "@primitivedotdev/sdk/x402"; import { createAuthenticatedCliApiClient } from "../api-client.js"; import { API_BASE_URL_FLAG_DESCRIPTION, @@ -12,7 +15,12 @@ import { writeErrorWithHints, } from "../api-command.js"; import { writeIdempotentReplayBannerIfReplay } from "../idempotent-replay-banner.js"; +import { deriveEmailChallengeFromInbound } from "./payments-email-challenge.js"; import { readEmailChallenge } from "./payments-pay-email-step.js"; +import { + pollForSettlementInteraction, + settlementWaitNotice, +} from "./payments-settlement.js"; import { PRIVATE_KEY_ENV, PRIVATE_KEY_FLAG_DESCRIPTION, @@ -20,6 +28,25 @@ import { signEmailChallenge, } from "./payments-shared.js"; +/** + * True when none of the explicit challenge sources are present (no --challenge, + * no --challenge-file, and stdin is an interactive TTY rather than a pipe). In + * that case `pay-email` auto-derives the challenge from the inbound email's + * interaction.json attachment. We treat a TTY stdin as "no challenge piped" so + * the one-shot does not block on readFileSync(0) waiting for the user to paste. + */ +export function shouldDeriveChallenge(flags: { + challenge?: string; + "challenge-file"?: string; +}): boolean { + if (flags.challenge !== undefined) return false; + if (flags["challenge-file"] !== undefined) return false; + // A piped stdin (not a TTY) means the caller is feeding a challenge in; honor + // it via readEmailChallenge. An interactive TTY means nothing is piped, so + // derive from --in-reply-to instead of hanging on stdin. + return process.stdin.isTTY === true; +} + // `primitive payments pay-email` is the one-shot payer side of an email-native // x402 payment: it SIGNs the challenge and SENDs the signed `interaction.json` // as a fresh message in a single step, so the payer does not have to run @@ -97,37 +124,67 @@ function emailDetailFromEnvelope( class PaymentsPayEmailCommand extends Command { static description = - `Pay an email-native x402 challenge in one step: sign it and send the signed interaction.json in-thread. - - Reads the email challenge the payee issued (the JSON from - \`payments create-email-challenge\`), derives and signs the interaction-bound - EIP-3009 authorization locally with your wallet key (read from - ${PRIVATE_KEY_ENV} by default), and sends the signed \`interaction.json\` - attached as an \`application/json\` part. The key never leaves your machine. - - This is the recommended payer path. It replaces the two-step - \`pay-email-step\` + \`send --attachment interaction.json\` dance: no manual - address or threading wrangling. Use \`pay-email-step\` only when you need the - signed bytes as a portable artifact without sending. - - --in-reply-to is the id of the inbound challenge email you received. It is - fetched to derive the recipient (the payee, from the inbound's sender) and the - From (you, the payer, from the inbound's recipient). The outgoing send does - NOT thread under the challenge: threading would trigger the send endpoint's - parent-thread dedup and the payment would never settle. The interaction - associates by interaction_id instead. Pass --from only to override the derived - payer From. - - Provide the challenge inline with --challenge, from a file with - --challenge-file, or piped on stdin.`; + `Pay an email-native x402 challenge in one step: sign it and send the signed interaction.json. + + THE ONE-COMMAND PAYER FLOW. You received a payment-request email carrying an + \`interaction.json\` attachment. Run: + + ${"<%= config.bin %>"} payments pay-email --in-reply-to + + with your wallet key in ${PRIVATE_KEY_ENV}, and this command auto-derives the + challenge from that inbound email's attachment, signs the interaction-bound + EIP-3009 authorization locally, and sends the signed \`interaction.json\` back + to the payee. The key never leaves your machine. You do NOT need --challenge or + --challenge-file: with just --in-reply-to the challenge is derived for you. + + THE CHALLENGE OBJECT (only needed for the --challenge / --challenge-file + override). \`--challenge\` expects the object the PAYEE's + \`create-email-challenge\` API returns, NOT the inbound email's + \`interaction.json\` attachment (those are different shapes). Its fields are: + + { + "interaction_id": "", // the email thread id + "challenge": { + "payment_requirements": { ... }, // scheme, network, payTo, asset, ... + "nonce_binding": { + "interaction_id": "", // must equal the outer interaction_id + "challenge_step_id": "", // the challenge step id + "challenge_nonce": "<64 hex chars>" + }, + "expires_at": "" + } + } + + As a real payer you usually have only the inbound email's attachment, whose + layout differs (top-level \`step\`, \`step_id\`, \`expires_at\`, and a nested + \`payload.payment_requirements\` / \`payload.challenge_nonce\`). You do NOT + reshape it by hand: omit --challenge and let --in-reply-to derive it, or run + \`payments challenge-from-email --id \` to print the correctly + shaped challenge object. Pass --challenge / --challenge-file only when you have + the payee-side challenge from another source; it is used as-is and overrides + the auto-derive. + + SETTLEMENT IS ASYNC. --wait only confirms the receiving MTA accepted the + message (SMTP 250); it does NOT confirm on-chain settlement. The \`settle_tx\` + hash arrives later in a follow-up inbound x402 settlement interaction email + from the payee. Use --wait-settle to poll for that settlement email after + sending and surface the receipt. + + ADDRESSING. --in-reply-to is the inbound challenge email's id. It is fetched to + derive the recipient (the payee, from the inbound's sender) and the From (you, + the payer, from the inbound's recipient). The outgoing send does NOT thread + under the challenge: threading would trigger the send endpoint's parent-thread + dedup and the payment would never settle. The interaction associates by + interaction_id instead. Pass --from only to override the derived payer From.`; static summary = - "Sign an email x402 challenge and send the signed interaction.json in-thread (one step)"; + "Sign an email x402 challenge and send the signed interaction.json (one step; derives the challenge from --in-reply-to)"; static examples = [ + "<%= config.bin %> payments pay-email --in-reply-to ", + "<%= config.bin %> payments pay-email --in-reply-to --wait-settle", "<%= config.bin %> payments pay-email --challenge-file challenge.json --in-reply-to ", - "cat challenge.json | <%= config.bin %> payments pay-email --in-reply-to --wait", - "<%= config.bin %> payments pay-email --challenge-file challenge.json --in-reply-to --from 'Payer '", + "<%= config.bin %> payments pay-email --in-reply-to --from 'Payer '", ]; static flags = { @@ -146,16 +203,18 @@ class PaymentsPayEmailCommand extends Command { env: PRIVATE_KEY_ENV, }), challenge: Flags.string({ - description: "The email challenge object as a JSON string.", + description: + "OVERRIDE: the payee-side email challenge object (the create-email-challenge response shape, NOT the inbound interaction.json attachment) as a JSON string. Optional: omit it and the challenge is auto-derived from the --in-reply-to email's attachment.", exclusive: ["challenge-file"], }), "challenge-file": Flags.string({ - description: "Path to a file containing the email challenge JSON.", + description: + "OVERRIDE: path to a file containing the payee-side email challenge JSON (the create-email-challenge response shape). Optional: omit it and the challenge is auto-derived from the --in-reply-to email's attachment.", exclusive: ["challenge"], }), "in-reply-to": Flags.string({ description: - "Id of the inbound challenge email you received. It is fetched to derive the payee recipient and the payer From. The outgoing send is not threaded under the challenge; the payment associates by interaction_id.", + "Id of the inbound challenge email you received. With no --challenge / --challenge-file, its interaction.json attachment is downloaded and reshaped into the challenge to sign. It is also fetched to derive the payee recipient and the payer From. The outgoing send is not threaded under the challenge; the payment associates by interaction_id.", required: true, }), from: Flags.string({ @@ -167,7 +226,23 @@ class PaymentsPayEmailCommand extends Command { }), wait: Flags.boolean({ description: - "Block until the receiving MTA returns an outcome. Without --wait, the call returns once Primitive has accepted the message for delivery.", + "Block until the receiving MTA returns a delivery outcome (SMTP 250). This confirms email DELIVERY only, NOT on-chain settlement. Without --wait, the call returns once Primitive has accepted the message for delivery.", + }), + "wait-settle": Flags.boolean({ + description: + "After sending, poll the inbox for the follow-up x402 settlement interaction email from the payee and print the settlement receipt (including settle_tx when present). Settlement is async, so this can take a while; tune with --settle-timeout / --settle-interval.", + }), + "settle-timeout": Flags.integer({ + default: 180, + description: + "With --wait-settle: seconds to wait for the settlement email before giving up (0 waits forever).", + min: 0, + }), + "settle-interval": Flags.integer({ + default: 5, + description: + "With --wait-settle: seconds between settlement-email polls.", + min: 1, }), json: Flags.boolean({ description: @@ -184,7 +259,7 @@ class PaymentsPayEmailCommand extends Command { // Unlike `pay-email-step` (fully offline), this command sends, so it needs // an authenticated client. Build it first so a not-signed-in caller gets // the standard auth guidance before we do any signing work. - const { apiClient, auth, baseUrlOverridden } = + const { apiClient, auth, baseUrlOverridden, requestConfig } = await createAuthenticatedCliApiClient({ apiKey: flags["api-key"], apiBaseUrl: flags["api-base-url"], @@ -197,33 +272,15 @@ class PaymentsPayEmailCommand extends Command { }; await runWithTiming(flags.time, async () => { - // Sign locally. Signing failures (bad key, expired/invalid challenge) - // surface through the x402 error reporter, the same as `pay-email-step`. - let built: BuiltPaymentStep; - try { - const challenge = readEmailChallenge({ - inline: flags.challenge, - file: flags["challenge-file"], - }); - built = await signEmailChallenge({ - challenge, - privateKey: flags["private-key"] ?? "", - resolvedApiBaseUrl: auth.apiBaseUrl, - apiKey: flags["api-key"], - }); - } catch (error) { - reportX402Error(error, authFailureContext); - process.exitCode = 1; - return; - } - - // Fetch the inbound challenge email to derive addressing. The payer is the - // address the challenge was sent to (the inbound's recipient); the payee - // is the inbound's sender. We deliberately do NOT carry the inbound's - // Message-Id onto the send (see the file header): threading the send under - // the challenge re-triggers the parent-thread dedup that swallows the - // payment. We resolve addressing here and hand it to the send path - // explicitly; the interaction associates by interaction_id, not threading. + // Fetch the inbound challenge email FIRST. It serves two purposes now: + // 1. addressing (payee = its sender, payer = its recipient), and + // 2. the source of the challenge to sign when no --challenge override is + // given (its interaction.json attachment is the wire envelope a real + // payer actually receives). + // We deliberately do NOT carry the inbound's Message-Id onto the send (see + // the file header): threading the send under the challenge re-triggers the + // parent-thread dedup that swallows the payment. The interaction associates + // by interaction_id, not threading. const inboundResult = await getEmail({ client: apiClient.client, path: { id: flags["in-reply-to"] }, @@ -250,6 +307,41 @@ class PaymentsPayEmailCommand extends Command { return; } + // Resolve the challenge. With an explicit --challenge / --challenge-file + // (or a piped stdin), use it as-is for back-compat. Otherwise auto-derive + // it from the inbound email's interaction.json attachment (the wire + // envelope) and reshape it via the SDK's canonical + // parseEmailChallengeFromPart. Then sign locally; signing failures (bad + // key, expired/invalid challenge, no interaction.json part) surface through + // the x402 error reporter, the same as `pay-email-step`. + let built: BuiltPaymentStep; + try { + let challenge: X402EmailChallenge; + if (shouldDeriveChallenge(flags)) { + challenge = await deriveEmailChallengeFromInbound({ + baseUrl: auth.apiBaseUrl, + emailId: flags["in-reply-to"], + apiKey: auth.apiKey, + headers: requestConfig.headers, + }); + } else { + challenge = readEmailChallenge({ + inline: flags.challenge, + file: flags["challenge-file"], + }); + } + built = await signEmailChallenge({ + challenge, + privateKey: flags["private-key"] ?? "", + resolvedApiBaseUrl: auth.apiBaseUrl, + apiKey: flags["api-key"], + }); + } catch (error) { + reportX402Error(error, authFailureContext); + process.exitCode = 1; + return; + } + // To = the payee that issued the challenge (the inbound's canonical // sender). From = the payer the challenge was addressed to (the inbound's // recipient), overridable with --from. We require a payee To; if the @@ -272,6 +364,10 @@ class PaymentsPayEmailCommand extends Command { return; } + // Capture the moment just before sending so --wait-settle only considers + // settlement emails that arrive AFTER this payment, not a stale prior one. + const sendStartedAt = new Date().toISOString(); + // Send the signed envelope. The inbound matcher requires a part named // exactly `interaction.json` with content type `application/json`; build // it that way. Always include a non-empty body_text (default, overridable @@ -331,20 +427,81 @@ class PaymentsPayEmailCommand extends Command { }); } + // Optionally wait for the async on-chain settlement. The payment is now on + // the wire, but settlement happens later and the settle_tx arrives in a + // follow-up x402 settlement interaction email from the payee. A replayed + // send put no fresh interaction.json on the wire, so polling for a + // settlement that will never come is pointless; skip the wait in that case. + let settlement: Awaited> = + null; + if (flags["wait-settle"] && !replayed && payeeTo) { + process.stderr.write( + "Payment sent. Waiting for the x402 settlement interaction email (settlement is async)...\n", + ); + settlement = await pollForSettlementInteraction({ + apiClient, + baseUrl: auth.apiBaseUrl, + apiKey: auth.apiKey, + headers: requestConfig.headers, + interactionId: built.envelope.interaction_id, + payeeFrom: payeeTo, + since: sendStartedAt, + timeoutSeconds: flags["settle-timeout"], + intervalSeconds: flags["settle-interval"], + }); + if (settlement) { + process.stderr.write( + settlement.settleTx + ? `Settled. settle_tx: ${settlement.settleTx}\n` + : "Settlement interaction received (no settle_tx field present; full receipt below).\n", + ); + } else { + process.stderr.write( + "Timed out waiting for the x402 settlement interaction email. The payment was sent; the settle_tx will arrive in a follow-up x402 settlement interaction email from the payee. Re-run with --wait-settle, or check your inbox for an x402 settlement message.\n", + ); + } + } + if (flags.json) { this.log( JSON.stringify( - { interaction: built.envelope, sent, idempotent_replay: replayed }, + { + interaction: built.envelope, + sent, + idempotent_replay: replayed, + ...(flags["wait-settle"] + ? { + settlement: settlement + ? { + email_id: settlement.emailId, + settle_tx: settlement.settleTx, + receipt: settlement.envelope, + } + : null, + } + : {}), + }, null, 2, ), ); } else { this.log(JSON.stringify(sent, null, 2)); + // Without --wait-settle the user has no on-chain confirmation yet; say so + // plainly so a delivered send is not mistaken for a settled payment. + if (!flags["wait-settle"]) { + process.stderr.write(`${settlementWaitNotice}\n`); + } else if (settlement) { + this.log(JSON.stringify(settlement.envelope, null, 2)); + } } if (replayed) { process.exitCode = 1; + } else if (flags["wait-settle"] && !settlement) { + // --wait-settle was requested but no settlement arrived in time: exit + // non-zero so automation does not treat an unconfirmed payment as done. + process.exitCode = 1; } }); } diff --git a/cli-node/src/oclif/commands/payments-settlement.ts b/cli-node/src/oclif/commands/payments-settlement.ts new file mode 100644 index 0000000..b6ecb8c --- /dev/null +++ b/cli-node/src/oclif/commands/payments-settlement.ts @@ -0,0 +1,175 @@ +import type { PrimitiveApiClient } from "@primitivedotdev/api-core"; +import { fetchEmailSearchPage, sleep } from "./emails-poll.js"; +import { fetchInteractionJsonBytes } from "./payments-email-challenge.js"; + +// Waiting for the ASYNC settlement of an email-native x402 payment. +// +// Sending the signed `interaction.json` (what `pay-email` does) only puts the +// payment on the wire. Settlement happens on-chain afterward, and the payee +// emails the payer a FOLLOW-UP x402 settlement interaction once it lands. So +// `--wait` (which confirms SMTP delivery of the payment, at most) cannot tell +// you the payment settled or surface the `settle_tx`. That arrives in the later +// inbound interaction email. +// +// This module polls the inbox for that settlement email and surfaces it. The +// match is deterministic on the one field guaranteed to be on the wire: the +// receipt's `interaction.json` carries the SAME `interaction_id` as the payment +// we sent, and is NOT the `challenge` step the payer originally received nor the +// `payment` step the payer itself sent. We therefore match by interaction_id + +// "later step", then print the whole receipt envelope so the caller can read the +// settlement details verbatim. `settle_tx` is additionally surfaced on a +// best-effort basis when the receipt carries a field of that name; the full +// envelope is always printed so nothing is hidden by a heuristic. + +/** The human-readable notice describing the async-settlement model. */ +export const settlementWaitNotice = + "Note: the payment has been SENT, but x402 settlement is asynchronous. --wait only confirms email delivery (SMTP 250), not on-chain settlement. The settle_tx hash arrives in a follow-up x402 settlement interaction email from the payee. Use --wait-settle to poll for it."; + +/** The steps a payer has already seen / sent, so a matching later step is the receipt. */ +const NON_RECEIPT_STEPS = new Set(["challenge", "payment"]); + +export interface SettlementReceipt { + /** The inbound settlement email's id. */ + emailId: string; + /** The parsed receipt interaction.json envelope (printed verbatim). */ + envelope: Record; + /** The on-chain settlement tx hash, when the receipt carries one. */ + settleTx: string | null; +} + +/** + * Best-effort extraction of a `settle_tx` from a receipt envelope. The exact + * receipt wire shape is owned by the platform and not pinned in the SDK, so we + * do not assert a rigid schema: we look for a `settle_tx` string at the top + * level or one level into `payload` (the two places interaction envelopes carry + * step data). Returns null when absent; the caller always prints the full + * envelope regardless, so a miss never hides data. + */ +export function extractSettleTx( + envelope: Record, +): string | null { + const top = envelope.settle_tx; + if (typeof top === "string" && top) return top; + const payload = envelope.payload; + if (payload && typeof payload === "object" && !Array.isArray(payload)) { + const nested = (payload as Record).settle_tx; + if (typeof nested === "string" && nested) return nested; + } + return null; +} + +/** + * Parse interaction.json bytes into an envelope object, or null when the bytes + * are not a JSON object (so a non-interaction attachment is simply skipped). + */ +export function parseInteractionEnvelope( + bytes: Uint8Array, +): Record | null { + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(bytes)); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + return parsed as Record; +} + +/** + * True when a downloaded interaction.json envelope is the settlement receipt for + * the interaction we paid: same `interaction_id`, and a step the payer has not + * already produced/received (i.e. not `challenge` / `payment`). Matching on the + * confirmed interaction_id keeps this correct without depending on the exact + * receipt step name, which the platform owns. + */ +export function isSettlementReceiptFor( + envelope: Record, + interactionId: string, +): boolean { + if (envelope.interaction_id !== interactionId) return false; + const step = envelope.step; + if (typeof step === "string" && NON_RECEIPT_STEPS.has(step)) return false; + return true; +} + +/** + * Poll the inbox for the x402 settlement interaction email that answers the + * payment we just sent, and return it once found. Returns null on timeout. + * + * Searches inbound mail from the payee (with attachments) received since the + * send, downloads each candidate's interaction.json, and matches it by + * interaction_id via {@link isSettlementReceiptFor}. Network access is via the + * injected api client (search) plus a plain fetch for the attachment archive, + * mirroring how the rest of the payments surface reads attachments; `fetchImpl` + * is injectable for tests. + */ +export async function pollForSettlementInteraction(params: { + apiClient: PrimitiveApiClient; + baseUrl: string; + apiKey?: string; + headers?: Record; + /** The interaction_id of the payment we sent (built.envelope.interaction_id). */ + interactionId: string; + /** The payee address the settlement email comes from. */ + payeeFrom: string; + /** ISO-8601 lower bound; only consider mail received at/after this. */ + since: string; + /** Total seconds to poll before giving up; 0 waits forever. */ + timeoutSeconds: number; + /** Seconds between polls. */ + intervalSeconds: number; + fetchImpl?: typeof fetch; +}): Promise { + const deadline = + params.timeoutSeconds === 0 + ? null + : Date.now() + params.timeoutSeconds * 1000; + const checked = new Set(); + + while (deadline === null || Date.now() < deadline) { + const page = await fetchEmailSearchPage({ + apiClient: params.apiClient, + filters: { from: params.payeeFrom, hasAttachment: true }, + pageSize: 50, + since: params.since, + }); + + if (page.ok) { + for (const row of page.rows) { + if (checked.has(row.id)) continue; + checked.add(row.id); + let bytes: Uint8Array | null; + try { + bytes = await fetchInteractionJsonBytes({ + baseUrl: params.baseUrl, + emailId: row.id, + apiKey: params.apiKey, + headers: params.headers, + fetchImpl: params.fetchImpl, + }); + } catch { + // A candidate whose archive can't be read is not the receipt we want; + // skip it and keep polling rather than aborting the wait. + continue; + } + if (!bytes) continue; + const envelope = parseInteractionEnvelope(bytes); + if (!envelope) continue; + if (isSettlementReceiptFor(envelope, params.interactionId)) { + return { + emailId: row.id, + envelope, + settleTx: extractSettleTx(envelope), + }; + } + } + } + + if (deadline !== null && Date.now() >= deadline) break; + await sleep(params.intervalSeconds * 1000); + } + + return null; +} diff --git a/cli-node/src/oclif/index.ts b/cli-node/src/oclif/index.ts index 6135695..09dbce9 100644 --- a/cli-node/src/oclif/index.ts +++ b/cli-node/src/oclif/index.ts @@ -35,6 +35,7 @@ import LogoutCommand from "./commands/logout.js"; import OrgSecretsListCommand from "./commands/org-secrets-list.js"; import OrgSecretsRemoveCommand from "./commands/org-secrets-remove.js"; import OrgSecretsSetCommand from "./commands/org-secrets-set.js"; +import PaymentsChallengeFromEmailCommand from "./commands/payments-challenge-from-email.js"; import PaymentsChargeCommand from "./commands/payments-charge.js"; import PaymentsPayCommand from "./commands/payments-pay.js"; import PaymentsPayEmailCommand from "./commands/payments-pay-email.js"; @@ -624,4 +625,10 @@ export const COMMANDS: Record = { // do; both share the exact signing path via `signEmailChallenge`. "payments:pay-email": PaymentsPayEmailCommand, "payments:pay-email-step": PaymentsPayEmailStepCommand, + // `challenge-from-email` reshapes an inbound payment-request email's + // interaction.json attachment (the wire envelope a payer actually receives) + // into the challenge object the signing commands consume. It exists so SDK and + // pay-email-step users can get the correctly-shaped challenge without + // hand-mapping the envelope; `pay-email --in-reply-to` derives it internally. + "payments:challenge-from-email": PaymentsChallengeFromEmailCommand, }; diff --git a/cli-node/tests/oclif/payments-email-challenge.test.ts b/cli-node/tests/oclif/payments-email-challenge.test.ts new file mode 100644 index 0000000..a207b72 --- /dev/null +++ b/cli-node/tests/oclif/payments-email-challenge.test.ts @@ -0,0 +1,230 @@ +import { gzipSync } from "node:zlib"; +import { describe, expect, it, vi } from "vitest"; +import { + attachmentsArchiveUrl, + deriveEmailChallengeFromInbound, + fetchInteractionJsonBytes, + interactionJsonFromArchive, + readTarEntries, +} from "../../src/oclif/commands/payments-email-challenge.js"; + +// The challenge-step interaction.json a payer receives on the inbound payment- +// request email. This is the WIRE ENVELOPE shape, byte-for-byte the fixture the +// SDK's parseEmailChallengeFromPart test uses, so the derived challenge matches +// the locked normative vector the signing tests pin. +const INTERACTION_ID = "a1b2c3d4-0000-0000-0000-000000000001@payer.example"; +const CHALLENGE_STEP_ID = "f00dface-0000-0000-0000-0000000000aa"; +const CHALLENGE_NONCE = + "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; + +function wireEnvelope(): Record { + return { + interaction_version: 1, + interaction_id: INTERACTION_ID, + protocol: "x402.payment", + protocol_version: 1, + step: "challenge", + step_id: CHALLENGE_STEP_ID, + prev_step_id: null, + expires_at: "2030-01-01T00:00:00.000Z", + payload: { + challenge_nonce: CHALLENGE_NONCE, + payment_requirements: { + scheme: "exact", + network: "base-sepolia", + maxAmountRequired: "10000", + payTo: "0x1111111111111111111111111111111111111111", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + extra: { name: "USDC", version: "2" }, + }, + }, + }; +} + +// --- A minimal ustar tar writer so the reader is exercised against real tar +// bytes (not a hand-rolled parser feeding its own output). --- +function octal(value: number, width: number): string { + return `${value.toString(8).padStart(width - 1, "0")}\0`; +} + +function tarEntry(name: string, content: Uint8Array): Uint8Array { + const header = new Uint8Array(512); + const enc = new TextEncoder(); + header.set(enc.encode(name).subarray(0, 100), 0); + header.set(enc.encode("0000644\0"), 100); // mode + header.set(enc.encode("0000000\0"), 108); // uid + header.set(enc.encode("0000000\0"), 116); // gid + header.set(enc.encode(octal(content.length, 12)), 124); // size + header.set(enc.encode("00000000000\0"), 136); // mtime + header.set(enc.encode("ustar\0"), 257); // magic + header.set(enc.encode("00"), 263); // version + header[156] = "0".charCodeAt(0); // typeflag = regular file + // Checksum: sum of all header bytes with the checksum field as spaces. + for (let i = 148; i < 156; i++) header[i] = 0x20; + let sum = 0; + for (const byte of header) sum += byte; + header.set(enc.encode(`${octal(sum, 7)} `), 148); + + const padded = new Uint8Array(Math.ceil(content.length / 512) * 512); + padded.set(content, 0); + const out = new Uint8Array(header.length + padded.length); + out.set(header, 0); + out.set(padded, header.length); + return out; +} + +function buildTar(files: Array<{ name: string; content: string }>): Uint8Array { + const enc = new TextEncoder(); + const parts = files.map((f) => tarEntry(f.name, enc.encode(f.content))); + const trailer = new Uint8Array(1024); // two zero blocks end the archive + const total = parts.reduce((acc, p) => acc + p.length, 0) + trailer.length; + const out = new Uint8Array(total); + let offset = 0; + for (const p of parts) { + out.set(p, offset); + offset += p.length; + } + out.set(trailer, offset); + return out; +} + +describe("attachmentsArchiveUrl", () => { + it("builds the per-email tarball URL and encodes the id", () => { + expect(attachmentsArchiveUrl("https://api.example/v1", "abc 123")).toBe( + "https://api.example/v1/emails/abc%20123/attachments.tar.gz", + ); + // A trailing slash on the base is normalized away. + expect(attachmentsArchiveUrl("https://api.example/v1/", "x")).toBe( + "https://api.example/v1/emails/x/attachments.tar.gz", + ); + }); +}); + +describe("readTarEntries", () => { + it("reads regular-file entries (name + bytes) and stops at the trailer", () => { + const tar = buildTar([ + { name: "a.txt", content: "hello" }, + { name: "interaction.json", content: '{"k":1}' }, + ]); + const entries = [...readTarEntries(tar)]; + expect(entries.map((e) => e.name)).toEqual(["a.txt", "interaction.json"]); + expect(new TextDecoder().decode(entries[1].bytes)).toBe('{"k":1}'); + }); +}); + +describe("interactionJsonFromArchive", () => { + it("extracts interaction.json from a gzipped tar, matching by basename", () => { + const tar = buildTar([ + { name: "attachments/note.txt", content: "ignore me" }, + { name: "attachments/interaction.json", content: '{"hello":"world"}' }, + ]); + const bytes = interactionJsonFromArchive(gzipSync(tar)); + expect(bytes).not.toBeNull(); + expect(new TextDecoder().decode(bytes as Uint8Array)).toBe( + '{"hello":"world"}', + ); + }); + + it("returns null when there is no interaction.json member", () => { + const tar = buildTar([{ name: "note.txt", content: "x" }]); + expect(interactionJsonFromArchive(gzipSync(tar))).toBeNull(); + }); +}); + +describe("fetchInteractionJsonBytes", () => { + it("fetches the tarball with a bearer token and returns the part bytes", async () => { + const tar = buildTar([ + { name: "interaction.json", content: JSON.stringify(wireEnvelope()) }, + ]); + const gz = gzipSync(tar); + const fetchImpl = vi.fn(async () => ({ + ok: true, + arrayBuffer: async () => + gz.buffer.slice(gz.byteOffset, gz.byteOffset + gz.byteLength), + })) as unknown as typeof fetch; + + const bytes = await fetchInteractionJsonBytes({ + baseUrl: "https://api.example/v1", + emailId: "inbound-1", + apiKey: "secret", + fetchImpl, + }); + expect(bytes).not.toBeNull(); + const call = (fetchImpl as unknown as { mock: { calls: unknown[][] } }).mock + .calls[0]; + expect(call[0]).toBe( + "https://api.example/v1/emails/inbound-1/attachments.tar.gz", + ); + expect( + (call[1] as { headers: Record }).headers.authorization, + ).toBe("Bearer secret"); + }); + + it("throws a descriptive error on a non-OK response", async () => { + const fetchImpl = vi.fn(async () => ({ + ok: false, + status: 404, + text: async () => "not found", + })) as unknown as typeof fetch; + await expect( + fetchInteractionJsonBytes({ + baseUrl: "https://api.example/v1", + emailId: "missing", + fetchImpl, + }), + ).rejects.toThrow(/HTTP 404/); + }); +}); + +describe("deriveEmailChallengeFromInbound", () => { + it("reshapes the inbound wire envelope into the signable challenge object", async () => { + const tar = buildTar([ + { name: "interaction.json", content: JSON.stringify(wireEnvelope()) }, + ]); + const gz = gzipSync(tar); + const fetchImpl = vi.fn(async () => ({ + ok: true, + arrayBuffer: async () => + gz.buffer.slice(gz.byteOffset, gz.byteOffset + gz.byteLength), + })) as unknown as typeof fetch; + + const challenge = await deriveEmailChallengeFromInbound({ + baseUrl: "https://api.example/v1", + emailId: "inbound-1", + fetchImpl, + }); + + // The reshape maps every settlement-critical field exactly. This is the + // mapping the signer depends on; a wrong map yields a bad signature. + expect(challenge.interaction_id).toBe(INTERACTION_ID); + expect(challenge.challenge.nonce_binding).toEqual({ + interaction_id: INTERACTION_ID, + challenge_step_id: CHALLENGE_STEP_ID, + challenge_nonce: CHALLENGE_NONCE, + }); + expect(challenge.challenge.expires_at).toBe("2030-01-01T00:00:00.000Z"); + expect(challenge.challenge.payment_requirements.maxAmountRequired).toBe( + "10000", + ); + expect(challenge.challenge.payment_requirements.payTo).toBe( + "0x1111111111111111111111111111111111111111", + ); + }); + + it("errors clearly when the inbound email has no interaction.json", async () => { + const tar = buildTar([{ name: "note.txt", content: "x" }]); + const gz = gzipSync(tar); + const fetchImpl = vi.fn(async () => ({ + ok: true, + arrayBuffer: async () => + gz.buffer.slice(gz.byteOffset, gz.byteOffset + gz.byteLength), + })) as unknown as typeof fetch; + await expect( + deriveEmailChallengeFromInbound({ + baseUrl: "https://api.example/v1", + emailId: "inbound-1", + fetchImpl, + }), + ).rejects.toThrow(/no interaction\.json attachment/); + }); +}); diff --git a/cli-node/tests/oclif/payments-pay-email.test.ts b/cli-node/tests/oclif/payments-pay-email.test.ts index b7410a7..5c57315 100644 --- a/cli-node/tests/oclif/payments-pay-email.test.ts +++ b/cli-node/tests/oclif/payments-pay-email.test.ts @@ -1,4 +1,5 @@ import { resolve } from "node:path"; +import { gzipSync } from "node:zlib"; import type { SendMailResult } from "@primitivedotdev/api-core"; import { createX402Client, @@ -113,6 +114,7 @@ const mocks = vi.hoisted(() => ({ createAuthenticatedCliApiClient: vi.fn(), getEmail: vi.fn(), sendEmail: vi.fn(), + searchEmails: vi.fn(), })); vi.mock("@primitivedotdev/api-core", async (importOriginal) => { @@ -122,6 +124,7 @@ vi.mock("@primitivedotdev/api-core", async (importOriginal) => { ...actual, getEmail: mocks.getEmail, sendEmail: mocks.sendEmail, + searchEmails: mocks.searchEmails, }; }); @@ -219,6 +222,7 @@ describe("payments pay-email (one-shot sign + send)", () => { credentials: null, }, baseUrlOverridden: false, + requestConfig: { headers: undefined }, }); mocks.getEmail.mockResolvedValue(inboundChallengeEmail()); mocks.sendEmail.mockResolvedValue({ data: { data: sendResult() } }); @@ -438,7 +442,7 @@ describe("payments pay-email (one-shot sign + send)", () => { expect(parsed.sent.id).toBe("sent-pay-email-1"); }); - it("does not fetch or send when signing fails (invalid challenge)", async () => { + it("does not send when signing fails (invalid challenge)", async () => { const result = await runPayEmailCommand([ "--challenge", JSON.stringify({ interaction_id: "x", challenge_id: "y" }), @@ -448,7 +452,9 @@ describe("payments pay-email (one-shot sign + send)", () => { TEST_KEY, ]); expect(result.exitCode).toBe(1); - expect(mocks.getEmail).not.toHaveBeenCalled(); + // The inbound email is now fetched first (it is the addressing source and, + // without an override, the challenge source), so getEmail may be called; but + // a signing failure must still short-circuit before any send goes out. expect(mocks.sendEmail).not.toHaveBeenCalled(); }); @@ -483,3 +489,373 @@ describe("payments pay-email (one-shot sign + send)", () => { expect(result.exitCode).toBe(1); }); }); + +// --- Auto-derive-from-inbound path (the headline fix): pay-email with ONLY +// --in-reply-to, no --challenge / --challenge-file. --- + +// Shared constants so the wire envelope (what the inbound attachment carries) +// and the equivalent --challenge object are byte-for-byte the same payment, and +// both sign to the same locked normative nonce vector. +const DERIVE_EXPIRES_AT = "2030-06-01T00:00:00.000Z"; + +// The challenge-step interaction.json the payer's inbound email carries: the +// WIRE ENVELOPE shape, with a different layout from the --challenge object. +function wireChallengeEnvelope(): Record { + return { + interaction_version: 1, + interaction_id: "a1b2c3d4-0000-0000-0000-000000000001@payer.example", + protocol: "x402.payment", + protocol_version: 1, + step: "challenge", + step_id: "f00dface-0000-0000-0000-0000000000aa", + prev_step_id: null, + expires_at: DERIVE_EXPIRES_AT, + payload: { + challenge_nonce: + "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899", + payment_requirements: { + scheme: "exact", + network: "base-sepolia", + maxAmountRequired: "10000", + payTo: "0x1111111111111111111111111111111111111111", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + extra: { name: "USDC", version: "2" }, + }, + }, + }; +} + +// The SAME payment expressed as the payee-side challenge object the --challenge +// override accepts. Sharing every field with the wire envelope above is what +// lets us assert the two paths produce the identical signed authorization. +function equivalentChallengeObject(): X402EmailChallenge { + return { + interaction_id: "a1b2c3d4-0000-0000-0000-000000000001@payer.example", + challenge_id: "", + challenge: { + payment_requirements: { + scheme: "exact", + network: "base-sepolia", + maxAmountRequired: "10000", + payTo: "0x1111111111111111111111111111111111111111", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + extra: { name: "USDC", version: "2" }, + }, + nonce_binding: { + interaction_id: "a1b2c3d4-0000-0000-0000-000000000001@payer.example", + challenge_step_id: "f00dface-0000-0000-0000-0000000000aa", + challenge_nonce: + "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899", + }, + expires_at: DERIVE_EXPIRES_AT, + }, + }; +} + +// Minimal ustar tar writer (one regular-file entry) so the command's real tar +// reader runs against genuine archive bytes. +function gzippedArchiveWith(name: string, content: string): Uint8Array { + const enc = new TextEncoder(); + const body = enc.encode(content); + const header = new Uint8Array(512); + const octal = (v: number, w: number) => + `${v.toString(8).padStart(w - 1, "0")}\0`; + header.set(enc.encode(name).subarray(0, 100), 0); + header.set(enc.encode("0000644\0"), 100); + header.set(enc.encode("0000000\0"), 108); + header.set(enc.encode("0000000\0"), 116); + header.set(enc.encode(octal(body.length, 12)), 124); + header.set(enc.encode("00000000000\0"), 136); + header.set(enc.encode("ustar\0"), 257); + header.set(enc.encode("00"), 263); + header[156] = "0".charCodeAt(0); + for (let i = 148; i < 156; i++) header[i] = 0x20; + let sum = 0; + for (const b of header) sum += b; + header.set(enc.encode(`${octal(sum, 7)} `), 148); + const padded = new Uint8Array(Math.ceil(body.length / 512) * 512); + padded.set(body, 0); + const tar = new Uint8Array(header.length + padded.length + 1024); + tar.set(header, 0); + tar.set(padded, header.length); + return gzipSync(tar); +} + +describe("payments pay-email (auto-derive challenge from inbound)", () => { + let originalFetch: typeof fetch; + let originalTTY: boolean | undefined; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.createAuthenticatedCliApiClient.mockResolvedValue({ + apiClient: { client: { host: "api" } }, + auth: { + source: "flag-or-env", + apiKey: "k", + apiBaseUrl: "https://api.example/v1", + credentials: null, + }, + baseUrlOverridden: false, + requestConfig: { headers: undefined }, + }); + mocks.getEmail.mockResolvedValue(inboundChallengeEmail()); + mocks.sendEmail.mockResolvedValue({ data: { data: sendResult() } }); + + // The auto-derive path downloads the attachments tarball via the global + // fetch (the network boundary we mock here). + originalFetch = globalThis.fetch; + const gz = gzippedArchiveWith( + "interaction.json", + JSON.stringify(wireChallengeEnvelope()), + ); + globalThis.fetch = vi.fn(async () => ({ + ok: true, + arrayBuffer: async () => + gz.buffer.slice(gz.byteOffset, gz.byteOffset + gz.byteLength), + })) as unknown as typeof fetch; + + // shouldDeriveChallenge() derives only when stdin is an interactive TTY (no + // piped challenge). Force it so the no-challenge path is exercised. + originalTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, "isTTY", { + value: true, + configurable: true, + }); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + Object.defineProperty(process.stdin, "isTTY", { + value: originalTTY, + configurable: true, + }); + process.exitCode = undefined; + }); + + it("derives the challenge from the inbound attachment with no --challenge and produces the locked normative nonce", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2030-01-01T00:00:00.000Z")); + try { + const result = await runPayEmailCommand([ + "--in-reply-to", + "inbound-challenge-1", + "--private-key", + TEST_KEY, + ]); + expect(result.exitCode).toBeUndefined(); + // The tarball was fetched from the inbound email's attachments endpoint. + const fetchMock = globalThis.fetch as unknown as { + mock: { calls: unknown[][] }; + }; + expect(fetchMock.mock.calls[0][0]).toBe( + "https://api.example/v1/emails/inbound-challenge-1/attachments.tar.gz", + ); + // The signed attachment carries the locked normative nonce, proving the + // reshaped wire envelope drove the exact same signing path. + const call = mocks.sendEmail.mock.calls[0][0]; + const attached = JSON.parse( + Buffer.from(call.body.attachments[0].content_base64, "base64").toString( + "utf8", + ), + ); + expect(attached.payload.payment.payload.authorization.nonce).toBe( + "0xc955a08812ab83f9e25c92e5162267b913957c3cc8678de1cf1449f77b516c6e", + ); + } finally { + vi.useRealTimers(); + } + }); + + it("produces the SAME signed authorization as passing the equivalent --challenge object", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2030-01-01T00:00:00.000Z")); + try { + // Auto-derived run (no --challenge). + await runPayEmailCommand([ + "--in-reply-to", + "inbound-challenge-1", + "--private-key", + TEST_KEY, + ]); + const derivedAttachment = JSON.parse( + Buffer.from( + mocks.sendEmail.mock.calls[0][0].body.attachments[0].content_base64, + "base64", + ).toString("utf8"), + ); + + mocks.sendEmail.mockClear(); + + // Explicit --challenge run with the equivalent challenge object. + await runPayEmailCommand([ + "--challenge", + JSON.stringify(equivalentChallengeObject()), + "--in-reply-to", + "inbound-challenge-1", + "--private-key", + TEST_KEY, + ]); + const overrideAttachment = JSON.parse( + Buffer.from( + mocks.sendEmail.mock.calls[0][0].body.attachments[0].content_base64, + "base64", + ).toString("utf8"), + ); + + // The signed payment payload (authorization + signature + derived nonce) + // must be byte-identical: deriving from the email attachment cannot produce + // a different settleable authorization than the hand-built challenge. Only + // the fresh per-build step_id differs by design. + expect(derivedAttachment.payload).toEqual(overrideAttachment.payload); + expect({ ...derivedAttachment, step_id: "X" }).toEqual({ + ...overrideAttachment, + step_id: "X", + }); + } finally { + vi.useRealTimers(); + } + }); + + it("the --challenge override skips the attachment download entirely", async () => { + await runPayEmailCommand([ + "--challenge", + JSON.stringify(equivalentChallengeObject()), + "--in-reply-to", + "inbound-challenge-1", + "--private-key", + TEST_KEY, + ]); + // With an explicit override the tarball is never fetched; only getEmail (for + // addressing) runs against the network. + const fetchMock = globalThis.fetch as unknown as { + mock: { calls: unknown[][] }; + }; + expect(fetchMock.mock.calls).toHaveLength(0); + expect(mocks.sendEmail).toHaveBeenCalledTimes(1); + }); +}); + +describe("payments pay-email --wait-settle", () => { + let originalFetch: typeof fetch; + let originalTTY: boolean | undefined; + + // The settlement receipt the payee emails back: same interaction_id as the + // payment we sent, a settlement step, and a settle_tx. + function receiptEnvelope(): Record { + return { + interaction_version: 1, + interaction_id: "a1b2c3d4-0000-0000-0000-000000000001@payer.example", + protocol: "x402.payment", + protocol_version: 1, + step: "settled", + step_id: "beadfeed-0000-0000-0000-0000000000bb", + settle_tx: "0xfeedface", + payload: { settle_tx: "0xfeedface" }, + }; + } + + beforeEach(() => { + vi.clearAllMocks(); + mocks.createAuthenticatedCliApiClient.mockResolvedValue({ + apiClient: { client: { host: "api" } }, + auth: { + source: "flag-or-env", + apiKey: "k", + apiBaseUrl: "https://api.example/v1", + credentials: null, + }, + baseUrlOverridden: false, + requestConfig: { headers: undefined }, + }); + mocks.getEmail.mockResolvedValue(inboundChallengeEmail()); + mocks.sendEmail.mockResolvedValue({ data: { data: sendResult() } }); + // The settlement poll finds one inbound email from the payee. + mocks.searchEmails.mockResolvedValue({ + data: { + data: [ + { id: "settlement-email-1", received_at: "2030-01-01T00:00:01.000Z" }, + ], + meta: { cursor: null }, + }, + }); + + // fetch is used for BOTH the challenge attachment (on the inbound id) and the + // receipt attachment (on the settlement email id); branch on the URL. + originalFetch = globalThis.fetch; + const challengeGz = gzippedArchiveWith( + "interaction.json", + JSON.stringify(wireChallengeEnvelope()), + ); + const receiptGz = gzippedArchiveWith( + "interaction.json", + JSON.stringify(receiptEnvelope()), + ); + const toAb = (u: Uint8Array) => + u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength); + globalThis.fetch = vi.fn(async (url: string) => ({ + ok: true, + arrayBuffer: async () => + url.includes("settlement-email-1") + ? toAb(receiptGz) + : toAb(challengeGz), + })) as unknown as typeof fetch; + + originalTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, "isTTY", { + value: true, + configurable: true, + }); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + Object.defineProperty(process.stdin, "isTTY", { + value: originalTTY, + configurable: true, + }); + process.exitCode = undefined; + }); + + it("polls for the settlement interaction and surfaces the settle_tx", async () => { + const result = await runPayEmailCommand([ + "--in-reply-to", + "inbound-challenge-1", + "--private-key", + TEST_KEY, + "--wait-settle", + "--settle-interval", + "1", + "--settle-timeout", + "30", + "--json", + ]); + expect(result.exitCode).toBeUndefined(); + expect(result.stderr).toContain("settle_tx: 0xfeedface"); + const parsed = JSON.parse(result.stdout); + expect(parsed.settlement.settle_tx).toBe("0xfeedface"); + expect(parsed.settlement.email_id).toBe("settlement-email-1"); + }); + + it("exits non-zero and explains the async settlement when no receipt arrives in time", async () => { + // No settlement email is ever found, so the poll times out. A 1s timeout + // keeps the test fast while still exercising the timeout branch. + mocks.searchEmails.mockResolvedValue({ + data: { data: [], meta: { cursor: null } }, + }); + const result = await runPayEmailCommand([ + "--in-reply-to", + "inbound-challenge-1", + "--private-key", + TEST_KEY, + "--wait-settle", + "--settle-timeout", + "1", + "--settle-interval", + "1", + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "Timed out waiting for the x402 settlement interaction email", + ); + }); +}); diff --git a/cli-node/tests/oclif/payments-settlement.test.ts b/cli-node/tests/oclif/payments-settlement.test.ts new file mode 100644 index 0000000..bf4f847 --- /dev/null +++ b/cli-node/tests/oclif/payments-settlement.test.ts @@ -0,0 +1,198 @@ +import { gzipSync } from "node:zlib"; +import type { PrimitiveApiClient } from "@primitivedotdev/api-core"; +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ searchEmails: vi.fn() })); + +vi.mock("@primitivedotdev/api-core", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, searchEmails: mocks.searchEmails }; +}); + +import { + extractSettleTx, + isSettlementReceiptFor, + parseInteractionEnvelope, + pollForSettlementInteraction, +} from "../../src/oclif/commands/payments-settlement.js"; + +const INTERACTION_ID = "a1b2c3d4-0000-0000-0000-000000000001@payer.example"; + +// A one-file ustar tar, gzipped, carrying the given interaction.json content. +function gzippedArchive(content: string): Uint8Array { + const enc = new TextEncoder(); + const body = enc.encode(content); + const header = new Uint8Array(512); + const octal = (v: number, w: number) => + `${v.toString(8).padStart(w - 1, "0")}\0`; + header.set(enc.encode("interaction.json").subarray(0, 100), 0); + header.set(enc.encode("0000644\0"), 100); + header.set(enc.encode("0000000\0"), 108); + header.set(enc.encode("0000000\0"), 116); + header.set(enc.encode(octal(body.length, 12)), 124); + header.set(enc.encode("00000000000\0"), 136); + header.set(enc.encode("ustar\0"), 257); + header.set(enc.encode("00"), 263); + header[156] = "0".charCodeAt(0); + for (let i = 148; i < 156; i++) header[i] = 0x20; + let sum = 0; + for (const b of header) sum += b; + header.set(enc.encode(`${octal(sum, 7)} `), 148); + const padded = new Uint8Array(Math.ceil(body.length / 512) * 512); + padded.set(body, 0); + const tar = new Uint8Array(header.length + padded.length + 1024); + tar.set(header, 0); + tar.set(padded, header.length); + return gzipSync(tar); +} + +describe("extractSettleTx", () => { + it("reads a top-level settle_tx", () => { + expect(extractSettleTx({ settle_tx: "0xabc" })).toBe("0xabc"); + }); + + it("reads a settle_tx nested one level into payload", () => { + expect(extractSettleTx({ payload: { settle_tx: "0xdef" } })).toBe("0xdef"); + }); + + it("returns null when no settle_tx is present", () => { + expect(extractSettleTx({ payload: { other: 1 } })).toBeNull(); + expect(extractSettleTx({})).toBeNull(); + }); +}); + +describe("parseInteractionEnvelope", () => { + it("parses a JSON object", () => { + const bytes = new TextEncoder().encode('{"a":1}'); + expect(parseInteractionEnvelope(bytes)).toEqual({ a: 1 }); + }); + + it("returns null for non-JSON or non-object bytes", () => { + expect( + parseInteractionEnvelope(new TextEncoder().encode("not json")), + ).toBeNull(); + expect( + parseInteractionEnvelope(new TextEncoder().encode("[1,2]")), + ).toBeNull(); + }); +}); + +describe("isSettlementReceiptFor", () => { + it("matches a later step with the same interaction_id", () => { + expect( + isSettlementReceiptFor( + { interaction_id: INTERACTION_ID, step: "settled" }, + INTERACTION_ID, + ), + ).toBe(true); + // A receipt with no step field still matches on interaction_id alone. + expect( + isSettlementReceiptFor( + { interaction_id: INTERACTION_ID }, + INTERACTION_ID, + ), + ).toBe(true); + }); + + it("rejects a different interaction_id", () => { + expect( + isSettlementReceiptFor( + { interaction_id: "other@x", step: "settled" }, + INTERACTION_ID, + ), + ).toBe(false); + }); + + it("rejects the challenge and payment steps the payer already saw/sent", () => { + expect( + isSettlementReceiptFor( + { interaction_id: INTERACTION_ID, step: "challenge" }, + INTERACTION_ID, + ), + ).toBe(false); + expect( + isSettlementReceiptFor( + { interaction_id: INTERACTION_ID, step: "payment" }, + INTERACTION_ID, + ), + ).toBe(false); + }); +}); + +describe("pollForSettlementInteraction", () => { + const apiClient = { + client: { host: "api" }, + } as unknown as PrimitiveApiClient; + + it("finds the settlement email matching the interaction_id and returns the settle_tx", async () => { + mocks.searchEmails.mockResolvedValue({ + data: { + data: [{ id: "settle-1", received_at: "2030-01-01T00:00:01.000Z" }], + meta: { cursor: null }, + }, + }); + const receipt = gzippedArchive( + JSON.stringify({ + interaction_id: INTERACTION_ID, + step: "settled", + settle_tx: "0xfeedface", + }), + ); + const fetchImpl = vi.fn(async () => ({ + ok: true, + arrayBuffer: async () => + receipt.buffer.slice( + receipt.byteOffset, + receipt.byteOffset + receipt.byteLength, + ), + })) as unknown as typeof fetch; + + const result = await pollForSettlementInteraction({ + apiClient, + baseUrl: "https://api.example/v1", + interactionId: INTERACTION_ID, + payeeFrom: "payee@payee.example", + since: "2030-01-01T00:00:00.000Z", + timeoutSeconds: 10, + intervalSeconds: 1, + fetchImpl, + }); + + expect(result).not.toBeNull(); + expect(result?.emailId).toBe("settle-1"); + expect(result?.settleTx).toBe("0xfeedface"); + }); + + it("ignores an email whose interaction.json is for a different interaction", async () => { + mocks.searchEmails.mockResolvedValue({ + data: { + data: [{ id: "other-1", received_at: "2030-01-01T00:00:01.000Z" }], + meta: { cursor: null }, + }, + }); + const otherReceipt = gzippedArchive( + JSON.stringify({ interaction_id: "different@x", step: "settled" }), + ); + const fetchImpl = vi.fn(async () => ({ + ok: true, + arrayBuffer: async () => + otherReceipt.buffer.slice( + otherReceipt.byteOffset, + otherReceipt.byteOffset + otherReceipt.byteLength, + ), + })) as unknown as typeof fetch; + + const result = await pollForSettlementInteraction({ + apiClient, + baseUrl: "https://api.example/v1", + interactionId: INTERACTION_ID, + payeeFrom: "payee@payee.example", + since: "2030-01-01T00:00:00.000Z", + timeoutSeconds: 1, + intervalSeconds: 1, + fetchImpl, + }); + expect(result).toBeNull(); + }); +}); diff --git a/sdk-go/VERSION b/sdk-go/VERSION index 850e742..63e799c 100644 --- a/sdk-go/VERSION +++ b/sdk-go/VERSION @@ -1 +1 @@ -1.14.0 +1.14.1 diff --git a/sdk-node/package.json b/sdk-node/package.json index 5d2b895..7ec51be 100644 --- a/sdk-node/package.json +++ b/sdk-node/package.json @@ -1,6 +1,6 @@ { "name": "@primitivedotdev/sdk", - "version": "1.14.0", + "version": "1.14.1", "description": "Official Primitive Node.js SDK: webhook, api, openapi, contract, and parser runtime modules.", "type": "module", "module": "./dist/index.js", diff --git a/sdk-python/pyproject.toml b/sdk-python/pyproject.toml index fe48d7a..e159b5e 100644 --- a/sdk-python/pyproject.toml +++ b/sdk-python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "primitivedotdev" -version = "1.14.0" +version = "1.14.1" description = "Official Primitive Python SDK for webhook handling and API access" readme = "README.md" requires-python = ">=3.10" diff --git a/sdk-python/uv.lock b/sdk-python/uv.lock index 1c3b2bb..b7aac72 100644 --- a/sdk-python/uv.lock +++ b/sdk-python/uv.lock @@ -1434,7 +1434,7 @@ wheels = [ [[package]] name = "primitivedotdev" -version = "1.14.0" +version = "1.14.1" source = { editable = "." } dependencies = [ { name = "attrs" }, From 83aca946b6743eaf3201d0233a3ab2e29fd95765 Mon Sep 17 00:00:00 2001 From: Ethan Byrd Date: Fri, 26 Jun 2026 23:40:32 -0700 Subject: [PATCH 2/5] Address Greptile P1s: derive without a TTY; retry transient settlement 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. --- .../src/oclif/commands/payments-pay-email.ts | 16 +++--- .../src/oclif/commands/payments-settlement.ts | 10 ++-- .../tests/oclif/payments-pay-email.test.ts | 25 ++++++++++ .../tests/oclif/payments-settlement.test.ts | 50 +++++++++++++++++++ 4 files changed, 92 insertions(+), 9 deletions(-) diff --git a/cli-node/src/oclif/commands/payments-pay-email.ts b/cli-node/src/oclif/commands/payments-pay-email.ts index f0ba80d..98c278e 100644 --- a/cli-node/src/oclif/commands/payments-pay-email.ts +++ b/cli-node/src/oclif/commands/payments-pay-email.ts @@ -39,12 +39,16 @@ export function shouldDeriveChallenge(flags: { challenge?: string; "challenge-file"?: string; }): boolean { - if (flags.challenge !== undefined) return false; - if (flags["challenge-file"] !== undefined) return false; - // A piped stdin (not a TTY) means the caller is feeding a challenge in; honor - // it via readEmailChallenge. An interactive TTY means nothing is piped, so - // derive from --in-reply-to instead of hanging on stdin. - return process.stdin.isTTY === true; + // Auto-derive whenever no explicit challenge source is given. This is the + // headline one-command flow (`pay-email --in-reply-to `) and it must work + // identically in an interactive terminal, in CI, and in Docker. We do NOT key + // this on `process.stdin.isTTY`: in CI / Docker / any non-interactive process + // stdin is not a TTY even though nothing is piped, so a TTY check would skip + // derivation there and then block on / mis-parse stdin. Unlike `pay-email-step` + // (a sign-only primitive that still reads a piped challenge from stdin), + // `pay-email` derives from --in-reply-to by default; pass --challenge / + // --challenge-file to override. + return flags.challenge === undefined && flags["challenge-file"] === undefined; } // `primitive payments pay-email` is the one-shot payer side of an email-native diff --git a/cli-node/src/oclif/commands/payments-settlement.ts b/cli-node/src/oclif/commands/payments-settlement.ts index b6ecb8c..fc57d0f 100644 --- a/cli-node/src/oclif/commands/payments-settlement.ts +++ b/cli-node/src/oclif/commands/payments-settlement.ts @@ -139,7 +139,6 @@ export async function pollForSettlementInteraction(params: { if (page.ok) { for (const row of page.rows) { if (checked.has(row.id)) continue; - checked.add(row.id); let bytes: Uint8Array | null; try { bytes = await fetchInteractionJsonBytes({ @@ -150,10 +149,15 @@ export async function pollForSettlementInteraction(params: { fetchImpl: params.fetchImpl, }); } catch { - // A candidate whose archive can't be read is not the receipt we want; - // skip it and keep polling rather than aborting the wait. + // The settlement email can be searchable before its attachment archive + // is ready, and a single fetch/gunzip can fail transiently. Do NOT mark + // it checked: leave it for a later poll so a transient miss within the + // timeout does not become a permanent skip. continue; } + // Only now is the email definitively read; record it so we don't refetch + // a no-interaction.json email every poll. + checked.add(row.id); if (!bytes) continue; const envelope = parseInteractionEnvelope(bytes); if (!envelope) continue; diff --git a/cli-node/tests/oclif/payments-pay-email.test.ts b/cli-node/tests/oclif/payments-pay-email.test.ts index 5c57315..5cd1543 100644 --- a/cli-node/tests/oclif/payments-pay-email.test.ts +++ b/cli-node/tests/oclif/payments-pay-email.test.ts @@ -733,6 +733,31 @@ describe("payments pay-email (auto-derive challenge from inbound)", () => { expect(fetchMock.mock.calls).toHaveLength(0); expect(mocks.sendEmail).toHaveBeenCalledTimes(1); }); + + it("derives in a non-interactive (non-TTY) process, the way CI / Docker run it", async () => { + // Regression guard: derivation must NOT depend on an interactive TTY. In CI / + // Docker stdin is not a TTY even though nothing is piped, so a TTY-gated + // derive would skip the attachment download and hang on / mis-parse stdin. + Object.defineProperty(process.stdin, "isTTY", { + value: false, + configurable: true, + }); + const result = await runPayEmailCommand([ + "--in-reply-to", + "inbound-challenge-1", + "--private-key", + TEST_KEY, + ]); + expect(result.exitCode).toBeUndefined(); + const fetchMock = globalThis.fetch as unknown as { + mock: { calls: unknown[][] }; + }; + // The attachment WAS downloaded (derivation ran) despite no TTY. + expect(fetchMock.mock.calls[0][0]).toBe( + "https://api.example/v1/emails/inbound-challenge-1/attachments.tar.gz", + ); + expect(mocks.sendEmail).toHaveBeenCalledTimes(1); + }); }); describe("payments pay-email --wait-settle", () => { diff --git a/cli-node/tests/oclif/payments-settlement.test.ts b/cli-node/tests/oclif/payments-settlement.test.ts index bf4f847..19e7f8b 100644 --- a/cli-node/tests/oclif/payments-settlement.test.ts +++ b/cli-node/tests/oclif/payments-settlement.test.ts @@ -195,4 +195,54 @@ describe("pollForSettlementInteraction", () => { }); expect(result).toBeNull(); }); + + it("retries an email whose attachment fetch fails transiently (no permanent skip)", async () => { + // The settlement email is searchable on every poll, but its attachment + // archive 404s the first time (not yet ready) and succeeds the second. The + // poll must NOT mark it checked on the failed attempt, or it would never + // re-fetch it and would time out despite the receipt arriving in time. + mocks.searchEmails.mockResolvedValue({ + data: { + data: [{ id: "settle-1", received_at: "2030-01-01T00:00:01.000Z" }], + meta: { cursor: null }, + }, + }); + const receipt = gzippedArchive( + JSON.stringify({ + interaction_id: INTERACTION_ID, + step: "settled", + settle_tx: "0xfeedface", + }), + ); + let attempt = 0; + const fetchImpl = vi.fn(async () => { + attempt += 1; + if (attempt === 1) { + // First attempt: archive not ready yet. + return { ok: false, status: 404, text: async () => "not ready" }; + } + return { + ok: true, + arrayBuffer: async () => + receipt.buffer.slice( + receipt.byteOffset, + receipt.byteOffset + receipt.byteLength, + ), + }; + }) as unknown as typeof fetch; + + const result = await pollForSettlementInteraction({ + apiClient, + baseUrl: "https://api.example/v1", + interactionId: INTERACTION_ID, + payeeFrom: "payee@payee.example", + since: "2030-01-01T00:00:00.000Z", + timeoutSeconds: 5, + intervalSeconds: 1, + fetchImpl, + }); + expect(attempt).toBeGreaterThanOrEqual(2); + expect(result?.emailId).toBe("settle-1"); + expect(result?.settleTx).toBe("0xfeedface"); + }); }); From 747577c6cdc844ea184f4ba531ab06be8bdd3e2c Mon Sep 17 00:00:00 2001 From: Ethan Byrd Date: Fri, 26 Jun 2026 23:46:45 -0700 Subject: [PATCH 3/5] Address Greptile P1: paginate the settlement poll across all search pages 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. --- .../src/oclif/commands/payments-settlement.ts | 40 ++++++++++--- .../tests/oclif/payments-settlement.test.ts | 58 ++++++++++++++++++- 2 files changed, 89 insertions(+), 9 deletions(-) diff --git a/cli-node/src/oclif/commands/payments-settlement.ts b/cli-node/src/oclif/commands/payments-settlement.ts index fc57d0f..c9826d3 100644 --- a/cli-node/src/oclif/commands/payments-settlement.ts +++ b/cli-node/src/oclif/commands/payments-settlement.ts @@ -129,14 +129,26 @@ export async function pollForSettlementInteraction(params: { const checked = new Set(); while (deadline === null || Date.now() < deadline) { - const page = await fetchEmailSearchPage({ - apiClient: params.apiClient, - filters: { from: params.payeeFrom, hasAttachment: true }, - pageSize: 50, - since: params.since, - }); + // Drain ALL pages each poll, not just the first 50. The search sorts + // received_at_asc, so a fresh receipt can land on a later page when many + // attachment-bearing emails from the payee arrive after `since`; reading only + // page one would loop over the same oldest rows and time out while the actual + // receipt sits beyond the page boundary. We re-scan from `since` each poll + // (rather than persisting the cursor) so a transiently-skipped email is + // retried; the `checked` set keeps already-read rows from being re-fetched. + let cursor: string | null = null; + let receipt: SettlementReceipt | null = null; + let drained = false; + while (!drained) { + const page = await fetchEmailSearchPage({ + apiClient: params.apiClient, + cursor, + filters: { from: params.payeeFrom, hasAttachment: true }, + pageSize: 50, + since: params.since, + }); + if (!page.ok) break; - if (page.ok) { for (const row of page.rows) { if (checked.has(row.id)) continue; let bytes: Uint8Array | null; @@ -162,15 +174,27 @@ export async function pollForSettlementInteraction(params: { const envelope = parseInteractionEnvelope(bytes); if (!envelope) continue; if (isSettlementReceiptFor(envelope, params.interactionId)) { - return { + receipt = { emailId: row.id, envelope, settleTx: extractSettleTx(envelope), }; + break; } } + + if (receipt) break; + // Advance to the next page; stop when there is no further cursor or the + // page came back empty (no more results in this window). + const nextCursor = page.cursor; + if (!nextCursor || nextCursor === cursor || page.rows.length === 0) { + drained = true; + } else { + cursor = nextCursor; + } } + if (receipt) return receipt; if (deadline !== null && Date.now() >= deadline) break; await sleep(params.intervalSeconds * 1000); } diff --git a/cli-node/tests/oclif/payments-settlement.test.ts b/cli-node/tests/oclif/payments-settlement.test.ts index 19e7f8b..4a2f2b2 100644 --- a/cli-node/tests/oclif/payments-settlement.test.ts +++ b/cli-node/tests/oclif/payments-settlement.test.ts @@ -1,6 +1,6 @@ import { gzipSync } from "node:zlib"; import type { PrimitiveApiClient } from "@primitivedotdev/api-core"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ searchEmails: vi.fn() })); @@ -125,6 +125,12 @@ describe("pollForSettlementInteraction", () => { client: { host: "api" }, } as unknown as PrimitiveApiClient; + beforeEach(() => { + // Reset the searchEmails mock between cases so a prior test's queued + // mockResolvedValueOnce / mockResolvedValue cannot leak into the next poll. + mocks.searchEmails.mockReset(); + }); + it("finds the settlement email matching the interaction_id and returns the settle_tx", async () => { mocks.searchEmails.mockResolvedValue({ data: { @@ -245,4 +251,54 @@ describe("pollForSettlementInteraction", () => { expect(result?.emailId).toBe("settle-1"); expect(result?.settleTx).toBe("0xfeedface"); }); + + it("follows the cursor to find a receipt that lands on a later search page", async () => { + // Page 1 (oldest-first) is full of non-receipt emails and returns a cursor; + // the receipt is on page 2. A first-page-only poll would never see it. + mocks.searchEmails + .mockResolvedValueOnce({ + data: { + data: [{ id: "noise-1", received_at: "2030-01-01T00:00:01.000Z" }], + meta: { cursor: "CURSOR_PAGE_2" }, + }, + }) + .mockResolvedValueOnce({ + data: { + data: [{ id: "settle-1", received_at: "2030-01-01T00:00:02.000Z" }], + meta: { cursor: null }, + }, + }); + const noise = gzippedArchive( + JSON.stringify({ interaction_id: "different@x", step: "settled" }), + ); + const receipt = gzippedArchive( + JSON.stringify({ + interaction_id: INTERACTION_ID, + step: "settled", + settle_tx: "0xfeedface", + }), + ); + const toAb = (u: Uint8Array) => + u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength); + const fetchImpl = vi.fn(async (url: string) => ({ + ok: true, + arrayBuffer: async () => + url.includes("settle-1") ? toAb(receipt) : toAb(noise), + })) as unknown as typeof fetch; + + const result = await pollForSettlementInteraction({ + apiClient, + baseUrl: "https://api.example/v1", + interactionId: INTERACTION_ID, + payeeFrom: "payee@payee.example", + since: "2030-01-01T00:00:00.000Z", + timeoutSeconds: 5, + intervalSeconds: 1, + fetchImpl, + }); + // Both pages were requested and the page-2 receipt was found. + expect(mocks.searchEmails).toHaveBeenCalledTimes(2); + expect(result?.emailId).toBe("settle-1"); + expect(result?.settleTx).toBe("0xfeedface"); + }); }); From 75978d2a3affd8948ec6103904b1de23f2ba66f7 Mon Sep 17 00:00:00 2001 From: Ethan Byrd Date: Fri, 26 Jun 2026 23:51:41 -0700 Subject: [PATCH 4/5] Address Greptile P1: do not mark a settlement email checked until its 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. --- .../src/oclif/commands/payments-settlement.ts | 11 +++-- .../tests/oclif/payments-settlement.test.ts | 45 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/cli-node/src/oclif/commands/payments-settlement.ts b/cli-node/src/oclif/commands/payments-settlement.ts index c9826d3..344e8b7 100644 --- a/cli-node/src/oclif/commands/payments-settlement.ts +++ b/cli-node/src/oclif/commands/payments-settlement.ts @@ -167,12 +167,17 @@ export async function pollForSettlementInteraction(params: { // timeout does not become a permanent skip. continue; } - // Only now is the email definitively read; record it so we don't refetch - // a no-interaction.json email every poll. - checked.add(row.id); + // Likewise, a null part (archive present but interaction.json not written + // yet) or an unparseable part (incomplete/partial JSON) is a transient + // not-ready state. Do NOT mark the row checked in those cases, or a + // receipt that becomes readable on the next poll would be skipped + // forever. if (!bytes) continue; const envelope = parseInteractionEnvelope(bytes); if (!envelope) continue; + // A fully parsed envelope is the email's final, readable state, so record + // it now to avoid re-fetching a non-receipt interaction.json each poll. + checked.add(row.id); if (isSettlementReceiptFor(envelope, params.interactionId)) { receipt = { emailId: row.id, diff --git a/cli-node/tests/oclif/payments-settlement.test.ts b/cli-node/tests/oclif/payments-settlement.test.ts index 4a2f2b2..e01134e 100644 --- a/cli-node/tests/oclif/payments-settlement.test.ts +++ b/cli-node/tests/oclif/payments-settlement.test.ts @@ -252,6 +252,51 @@ describe("pollForSettlementInteraction", () => { expect(result?.settleTx).toBe("0xfeedface"); }); + it("retries an email whose interaction.json is not readable yet, not a permanent skip", async () => { + // The email is searchable, but on the first poll its interaction.json is + // incomplete/empty (unparseable JSON), and on a later poll it carries the + // full receipt. An unparseable (or null) part must NOT mark the row checked, + // or the receipt that becomes readable later would be skipped forever. + mocks.searchEmails.mockResolvedValue({ + data: { + data: [{ id: "settle-1", received_at: "2030-01-01T00:00:01.000Z" }], + meta: { cursor: null }, + }, + }); + const notReady = gzippedArchive(""); // interaction.json present but empty -> unparseable + const receipt = gzippedArchive( + JSON.stringify({ + interaction_id: INTERACTION_ID, + step: "settled", + settle_tx: "0xfeedface", + }), + ); + const toAb = (u: Uint8Array) => + u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength); + let attempt = 0; + const fetchImpl = vi.fn(async () => { + attempt += 1; + return { + ok: true, + arrayBuffer: async () => + attempt === 1 ? toAb(notReady) : toAb(receipt), + }; + }) as unknown as typeof fetch; + + const result = await pollForSettlementInteraction({ + apiClient, + baseUrl: "https://api.example/v1", + interactionId: INTERACTION_ID, + payeeFrom: "payee@payee.example", + since: "2030-01-01T00:00:00.000Z", + timeoutSeconds: 5, + intervalSeconds: 1, + fetchImpl, + }); + expect(attempt).toBeGreaterThanOrEqual(2); + expect(result?.settleTx).toBe("0xfeedface"); + }); + it("follows the cursor to find a receipt that lands on a later search page", async () => { // Page 1 (oldest-first) is full of non-receipt emails and returns a cursor; // the receipt is on page 2. A first-page-only poll would never see it. From 165bf5e872f20f8fd3b5e3b3f41275aa4850bb11 Mon Sep 17 00:00:00 2001 From: Ethan Byrd Date: Sat, 27 Jun 2026 00:32:08 -0700 Subject: [PATCH 5/5] Locate the inbound challenge attachment by its archive entry name, not a bare filename The attachments archive names each entry _, 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 _ part-index prefix when metadata is unavailable (the settlement receipt poll has no metadata). Tests now use the real 0_interaction.json naming. --- .../commands/payments-email-challenge.ts | Bin 9209 -> 13877 bytes .../src/oclif/commands/payments-pay-email.ts | 6 + .../oclif/payments-email-challenge.test.ts | 127 ++++++++++++++++-- .../tests/oclif/payments-pay-email.test.ts | 28 +++- .../tests/oclif/payments-settlement.test.ts | 5 +- 5 files changed, 153 insertions(+), 13 deletions(-) diff --git a/cli-node/src/oclif/commands/payments-email-challenge.ts b/cli-node/src/oclif/commands/payments-email-challenge.ts index f82ebfa8b476a0e65a8b69ff413c3686c6a2c1b1..46eb56891d1933a8d044ef497eaaf8e44aa30b8d 100644 GIT binary patch delta 4500 zcmd5$hNscv~ znM@D0qNT+gf*^*E<}ta(X9I$Nf`33Rx#yUlkXv3=&zE*-TL&L}SYR>iuIhgE-mB{W zJ@_{G=5N=sZnx3x((w57;n}EvGNMo|{cuj6PL&_rrF5Z5CDVofM3Wb3LnjYqjV7(8 z3({eltZA|)KQ%N_iRNH4KhOxH9U45*_<5<96P=K6C{Fauf2ut?_tS;!$v)Rfv^P-( zACHH}1Bz9Wk{^2dX@^Y2`8nitp#lV*<1lH8#7iXRfbN~KAEr7{)6|bb=Q9(9F14bB z+yjZxyZUpx!vVu6B^O&a|K52cb7+KX>LqUco~dUz9*p`Q^+$bbF%E8(+qfJ=C5c*7 z6w-sih`O@hbf5X&MVqGh%?Dh(Pd~-+N)#<*j1R6!ZmN=O9IJF;BL-18H@>H3<6^Z` zSW@8W3L@tV9n!g`g+fWvg-U^Msyvk{{u)Hl;{voC_f=dJh?Ch|4*h|{*qs@+0?SpX zo&W(g2{g5|Gj~Yg8h|~KG<@MG4Aw}TUyJb~-)na`@eC`$fGM&^v{Wf50~*Qog(K&S z$Y?AuLJe#I0|dix{aXsO@_4y>;Q4bDGg6XgR}m<(eUBP__TjE8sB?}FDIk% z=#QrZI;5tGpP=r;9*jG3^ptODRkbh=7HP#WAo1^sXmtwOJ4 zmf0>3`juOC*Vf9h*t9jui$`l9YSR(uJq`m-&>%?4p>LoT7!g{)|K(>b~;JOP=WQ@9F(ef)+(Ge5@6k8Qsfve@<;`w>h8euQ7c6+78}V|_Q5wtK+G`_P`RH$EtwqUUxv zB>Z!$LGJZfI++D|whF~Ot-MYVZ^*Yj6{~I)rz%iw7yl50Zh2lEJ<)N1I<>l={>giP z-0e`a*{-8?rcD%xZGmLj_u1kM{>{YPs$xM&eB#BD0}nY>&Pq=jl#M>lr? zlJeNj``p70whF5U3Bk6|vf0Y4oH~t)0rra!!JkpF_#`97=0Ce{Jp$DUEEAa{%#<5VNQbha7W3#x zzFY}h#;myn;$F|{eL&*NC2jaCS3q;L)feW%xX?1#`|{CA*vGS#-lqsfOU`}xY@($F zB`b^vSS-{Qza@mrq{_AdzF!u(^Kx&^<6~+m2cDVfU4?^wvaymlMY#ki~5Ykq& z+iW{=6t~)3{rtB!aVHLf&EwrjLn#AkBt_-+yhO?`Mac)*ZI^4aEUYkUX91hJ#m>ja{X7J9P}kScUq+otiheIVIAxIE9x zR*%(CQax*f2+sqne|`tE7ujbPVPuBcvnzxUtqnI{Y4a&}cd5eF4k7Ho^!rl7hLT>f zW3w_|{Qc(FJBLLyy;xSmF>b8pS^kb+RqTj?1ymV zViT&n-jjKe=Oze21^nJv(@f)yYG3HKF0zPPJbiK}?qUk_G`vJ!esE(j9Zfn`#>g&t SpnaV1Bk8B8*gtQLcK#2&a0K1} delta 130 zcmdm*^V5ApJLlvWo{r5XqP4u13ciUY$r-7|3MCn-3c0DdNvTB&NtFsoiN&dTiMgo? z#rX<}3OT8XDVcfc3MrXIsmUezMU`9%T9boR@+RL_v6-Brn$4b>rl0}TGPzz@eDVV| i&dKl9q&H8|Xk^+ftFwe@GPAzsW>bAz=FN|dgO~tA0WOdL diff --git a/cli-node/src/oclif/commands/payments-pay-email.ts b/cli-node/src/oclif/commands/payments-pay-email.ts index 98c278e..affb054 100644 --- a/cli-node/src/oclif/commands/payments-pay-email.ts +++ b/cli-node/src/oclif/commands/payments-pay-email.ts @@ -327,6 +327,12 @@ class PaymentsPayEmailCommand extends Command { emailId: flags["in-reply-to"], apiKey: auth.apiKey, headers: requestConfig.headers, + // The attachments archive names each entry `_` + // (e.g. `0_interaction.json`), so locate the challenge member by its + // metadata `tar_path` rather than guessing the entry name. The + // inbound email is already fetched above for from/to derivation, so + // the metadata is in hand. + attachments: inbound.parsed?.attachments, }); } else { challenge = readEmailChallenge({ diff --git a/cli-node/tests/oclif/payments-email-challenge.test.ts b/cli-node/tests/oclif/payments-email-challenge.test.ts index a207b72..19dd40b 100644 --- a/cli-node/tests/oclif/payments-email-challenge.test.ts +++ b/cli-node/tests/oclif/payments-email-challenge.test.ts @@ -5,9 +5,25 @@ import { deriveEmailChallengeFromInbound, fetchInteractionJsonBytes, interactionJsonFromArchive, + interactionTarPathFromMeta, readTarEntries, } from "../../src/oclif/commands/payments-email-challenge.js"; +// The real attachments archive names each member `_`, so +// the challenge attachment is `0_interaction.json`, NOT `interaction.json`. The +// email's attachment metadata carries that exact entry name in `tar_path`. These +// constants mirror the confirmed live shape so the tests fail if the derive ever +// regresses to guessing the bare filename again. +const TAR_ENTRY = "0_interaction.json"; +const ATTACHMENT_META = [ + { + filename: "interaction.json", + tar_path: TAR_ENTRY, + part_index: 0, + content_type: "application/json", + }, +]; + // The challenge-step interaction.json a payer receives on the inbound payment- // request email. This is the WIRE ENVELOPE shape, byte-for-byte the fixture the // SDK's parseEmailChallengeFromPart test uses, so the derived challenge matches @@ -112,11 +128,56 @@ describe("readTarEntries", () => { }); }); +describe("interactionTarPathFromMeta", () => { + it("resolves the archive entry name from the matching filename's tar_path", () => { + expect(interactionTarPathFromMeta(ATTACHMENT_META)).toBe(TAR_ENTRY); + }); + + it("falls back to an application/json part when filename is absent", () => { + expect( + interactionTarPathFromMeta([ + { content_type: "text/plain", tar_path: "0_note.txt" }, + { content_type: "application/json", tar_path: "1_payload.json" }, + ]), + ).toBe("1_payload.json"); + }); + + it("reconstructs _ when tar_path is missing", () => { + expect( + interactionTarPathFromMeta([ + { filename: "interaction.json", part_index: 2 }, + ]), + ).toBe("2_interaction.json"); + }); + + it("returns null when no interaction.json attachment is described", () => { + expect(interactionTarPathFromMeta([])).toBeNull(); + expect(interactionTarPathFromMeta(undefined)).toBeNull(); + expect( + interactionTarPathFromMeta([ + { filename: "note.txt", content_type: "text/plain" }, + ]), + ).toBeNull(); + }); +}); + describe("interactionJsonFromArchive", () => { - it("extracts interaction.json from a gzipped tar, matching by basename", () => { + it("extracts the entry named by tar_path (the real _ prefix)", () => { const tar = buildTar([ - { name: "attachments/note.txt", content: "ignore me" }, - { name: "attachments/interaction.json", content: '{"hello":"world"}' }, + { name: "0_note.txt", content: "ignore me" }, + { name: TAR_ENTRY, content: '{"hello":"world"}' }, + ]); + const bytes = interactionJsonFromArchive(gzipSync(tar), TAR_ENTRY); + expect(bytes).not.toBeNull(); + expect(new TextDecoder().decode(bytes as Uint8Array)).toBe( + '{"hello":"world"}', + ); + }); + + it("falls back to a _ prefixed entry when no tar_path is given", () => { + const tar = buildTar([ + { name: "attachments/0_note.txt", content: "ignore me" }, + { name: "attachments/0_interaction.json", content: '{"hello":"world"}' }, ]); const bytes = interactionJsonFromArchive(gzipSync(tar)); expect(bytes).not.toBeNull(); @@ -125,16 +186,36 @@ describe("interactionJsonFromArchive", () => { ); }); + it("still resolves a bare interaction.json entry via the fallback", () => { + const tar = buildTar([{ name: "interaction.json", content: '{"k":1}' }]); + const bytes = interactionJsonFromArchive(gzipSync(tar)); + expect(new TextDecoder().decode(bytes as Uint8Array)).toBe('{"k":1}'); + }); + + it("falls back to the prefix match when tar_path is absent from the archive", () => { + const tar = buildTar([{ name: "0_interaction.json", content: '{"k":2}' }]); + // Metadata claims an entry name the archive does not contain; the + // prefix-stripping fallback must still find the real member. + const bytes = interactionJsonFromArchive( + gzipSync(tar), + "9_interaction.json", + ); + expect(new TextDecoder().decode(bytes as Uint8Array)).toBe('{"k":2}'); + }); + it("returns null when there is no interaction.json member", () => { - const tar = buildTar([{ name: "note.txt", content: "x" }]); + const tar = buildTar([{ name: "0_note.txt", content: "x" }]); expect(interactionJsonFromArchive(gzipSync(tar))).toBeNull(); + expect( + interactionJsonFromArchive(gzipSync(tar), "0_interaction.json"), + ).toBeNull(); }); }); describe("fetchInteractionJsonBytes", () => { - it("fetches the tarball with a bearer token and returns the part bytes", async () => { + it("fetches the tarball with a bearer token and returns the part bytes by tar_path", async () => { const tar = buildTar([ - { name: "interaction.json", content: JSON.stringify(wireEnvelope()) }, + { name: TAR_ENTRY, content: JSON.stringify(wireEnvelope()) }, ]); const gz = gzipSync(tar); const fetchImpl = vi.fn(async () => ({ @@ -148,6 +229,7 @@ describe("fetchInteractionJsonBytes", () => { emailId: "inbound-1", apiKey: "secret", fetchImpl, + attachments: ATTACHMENT_META, }); expect(bytes).not.toBeNull(); const call = (fetchImpl as unknown as { mock: { calls: unknown[][] } }).mock @@ -177,9 +259,13 @@ describe("fetchInteractionJsonBytes", () => { }); describe("deriveEmailChallengeFromInbound", () => { - it("reshapes the inbound wire envelope into the signable challenge object", async () => { + it("reshapes the inbound wire envelope into the signable challenge object (real 0_ prefixed entry + metadata)", async () => { + // Regression for the real archive naming: the entry is `0_interaction.json` + // and the email metadata's `tar_path` points at it. The pre-fix code looked + // for a bare `interaction.json` and missed this, failing a real payment. const tar = buildTar([ - { name: "interaction.json", content: JSON.stringify(wireEnvelope()) }, + { name: "0_note.txt", content: "an unrelated attachment" }, + { name: TAR_ENTRY, content: JSON.stringify(wireEnvelope()) }, ]); const gz = gzipSync(tar); const fetchImpl = vi.fn(async () => ({ @@ -192,6 +278,7 @@ describe("deriveEmailChallengeFromInbound", () => { baseUrl: "https://api.example/v1", emailId: "inbound-1", fetchImpl, + attachments: ATTACHMENT_META, }); // The reshape maps every settlement-critical field exactly. This is the @@ -211,8 +298,29 @@ describe("deriveEmailChallengeFromInbound", () => { ); }); + it("reshapes via the prefix fallback when no attachment metadata is passed", async () => { + // Callers that only have the email id (no fetched metadata) still resolve the + // `0_interaction.json` entry via the prefix-stripping fallback. + const tar = buildTar([ + { name: TAR_ENTRY, content: JSON.stringify(wireEnvelope()) }, + ]); + const gz = gzipSync(tar); + const fetchImpl = vi.fn(async () => ({ + ok: true, + arrayBuffer: async () => + gz.buffer.slice(gz.byteOffset, gz.byteOffset + gz.byteLength), + })) as unknown as typeof fetch; + + const challenge = await deriveEmailChallengeFromInbound({ + baseUrl: "https://api.example/v1", + emailId: "inbound-1", + fetchImpl, + }); + expect(challenge.interaction_id).toBe(INTERACTION_ID); + }); + it("errors clearly when the inbound email has no interaction.json", async () => { - const tar = buildTar([{ name: "note.txt", content: "x" }]); + const tar = buildTar([{ name: "0_note.txt", content: "x" }]); const gz = gzipSync(tar); const fetchImpl = vi.fn(async () => ({ ok: true, @@ -224,6 +332,7 @@ describe("deriveEmailChallengeFromInbound", () => { baseUrl: "https://api.example/v1", emailId: "inbound-1", fetchImpl, + attachments: [{ filename: "note.txt", content_type: "text/plain" }], }), ).rejects.toThrow(/no interaction\.json attachment/); }); diff --git a/cli-node/tests/oclif/payments-pay-email.test.ts b/cli-node/tests/oclif/payments-pay-email.test.ts index 5cd1543..5c5fb48 100644 --- a/cli-node/tests/oclif/payments-pay-email.test.ts +++ b/cli-node/tests/oclif/payments-pay-email.test.ts @@ -154,6 +154,21 @@ function inboundChallengeEmail() { to_email: "payer@payer.example", recipient: "payer@payer.example", sender: "payee@payee.example", + // Real inbound attachment metadata: the challenge member's archive entry + // is `0_interaction.json` (the `_` scheme), which + // the auto-derive uses to locate the part inside the tarball. + parsed: { + status: "complete", + attachments: [ + { + filename: "interaction.json", + tar_path: "0_interaction.json", + part_index: 0, + size_bytes: 768, + content_type: "application/json", + }, + ], + }, }, }, }; @@ -604,8 +619,11 @@ describe("payments pay-email (auto-derive challenge from inbound)", () => { // The auto-derive path downloads the attachments tarball via the global // fetch (the network boundary we mock here). originalFetch = globalThis.fetch; + // The real archive names the entry `0_interaction.json`, not a bare + // `interaction.json`. Using the real name here is the regression guard: the + // pre-fix derive matched the unprefixed name and would miss this. const gz = gzippedArchiveWith( - "interaction.json", + "0_interaction.json", JSON.stringify(wireChallengeEnvelope()), ); globalThis.fetch = vi.fn(async () => ({ @@ -807,12 +825,16 @@ describe("payments pay-email --wait-settle", () => { // fetch is used for BOTH the challenge attachment (on the inbound id) and the // receipt attachment (on the settlement email id); branch on the URL. originalFetch = globalThis.fetch; + // Both archives use the real `0_interaction.json` entry name. The settlement + // poll has no attachment metadata (it only has search rows), so it resolves + // the receipt via the prefix-stripping fallback; the challenge derive uses + // the email metadata's tar_path. const challengeGz = gzippedArchiveWith( - "interaction.json", + "0_interaction.json", JSON.stringify(wireChallengeEnvelope()), ); const receiptGz = gzippedArchiveWith( - "interaction.json", + "0_interaction.json", JSON.stringify(receiptEnvelope()), ); const toAb = (u: Uint8Array) => diff --git a/cli-node/tests/oclif/payments-settlement.test.ts b/cli-node/tests/oclif/payments-settlement.test.ts index e01134e..2677847 100644 --- a/cli-node/tests/oclif/payments-settlement.test.ts +++ b/cli-node/tests/oclif/payments-settlement.test.ts @@ -20,13 +20,16 @@ import { const INTERACTION_ID = "a1b2c3d4-0000-0000-0000-000000000001@payer.example"; // A one-file ustar tar, gzipped, carrying the given interaction.json content. +// The entry uses the real archive name `0_interaction.json` (the +// `_` scheme); the settlement poll has no attachment +// metadata, so it must resolve this via the prefix-stripping fallback. function gzippedArchive(content: string): Uint8Array { const enc = new TextEncoder(); const body = enc.encode(content); const header = new Uint8Array(512); const octal = (v: number, w: number) => `${v.toString(8).padStart(w - 1, "0")}\0`; - header.set(enc.encode("interaction.json").subarray(0, 100), 0); + header.set(enc.encode("0_interaction.json").subarray(0, 100), 0); header.set(enc.encode("0000644\0"), 100); header.set(enc.encode("0000000\0"), 108); header.set(enc.encode("0000000\0"), 116);