@primitivedotdev/sdk 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { concat, getAddress, hexToBytes, keccak256, stringToBytes } from "viem";
2
3
  //#region src/x402/sign.ts
3
4
  /**
@@ -97,6 +98,44 @@ function buildPayoutRegistrationMessage(input) {
97
98
  `issued: ${input.issuedAt}`
98
99
  ].join("\n");
99
100
  }
101
+ /**
102
+ * The protocol the email-native payment interaction runs (`x402.payment/1`).
103
+ * The payer's reply carries the `payment` step of this protocol.
104
+ */
105
+ const X402_INTERACTION_PROTOCOL = "x402.payment";
106
+ const X402_INTERACTION_PROTOCOL_VERSION = 1;
107
+ /** A UUID (used for `interaction_id`'s local part and the step ids). */
108
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
109
+ /** An interaction id is `uuid@domain`. */
110
+ const WIRE_ID_RE = new RegExp(`^${UUID_RE.source.slice(1, -1)}@[^\\s@]+$`, "i");
111
+ /**
112
+ * Build the section-2.3 interaction.json envelope for a `payment` step. Pure: no
113
+ * I/O. `payment` is the signed exact-EVM payload (from
114
+ * `buildExactEvmPaymentPayload`); `prevStepId` is the challenge step id this
115
+ * payment answers, and `stepId` is a fresh UUID for the payment step. Returns
116
+ * the envelope and its canonical JSON, so the bytes the platform reads back are
117
+ * exactly the ones produced here.
118
+ */
119
+ function buildPaymentStepEnvelope(params) {
120
+ if (!WIRE_ID_RE.test(params.interactionId)) throw new Error("buildPaymentStepEnvelope: interactionId must be uuid@domain");
121
+ if (!UUID_RE.test(params.stepId)) throw new Error("buildPaymentStepEnvelope: stepId must be a uuid");
122
+ if (!UUID_RE.test(params.prevStepId)) throw new Error("buildPaymentStepEnvelope: prevStepId must be a uuid");
123
+ const envelope = {
124
+ interaction_version: 1,
125
+ interaction_id: params.interactionId,
126
+ protocol: X402_INTERACTION_PROTOCOL,
127
+ protocol_version: 1,
128
+ step: "payment",
129
+ step_id: params.stepId,
130
+ prev_step_id: params.prevStepId,
131
+ expires_at: params.expiresAt ?? null,
132
+ payload: { payment: params.payment }
133
+ };
134
+ return {
135
+ envelope,
136
+ json: JSON.stringify(envelope)
137
+ };
138
+ }
100
139
  /** Assemble the wire payload from a signed authorization. */
101
140
  function toPaymentPayload(network, auth, signature) {
102
141
  return {
@@ -184,6 +223,15 @@ function buildExactEvmPaymentPayload(params) {
184
223
  }
185
224
  //#endregion
186
225
  //#region src/x402/client.ts
226
+ /**
227
+ * x402 agent-to-agent payments.
228
+ *
229
+ * `charge()` (payee) asks for a payment; `pay()` (payer) signs and settles it
230
+ * with the customer's own key. The signing is local and non-custodial; the key
231
+ * never leaves the caller. The server resolves the real payee address, verifies
232
+ * every signed field against its own records, and enforces the spend policy, so
233
+ * the SDK's job is just: derive the bound authorization, sign, and submit.
234
+ */
187
235
  const CHAIN_IDS = {
188
236
  "base-sepolia": 84532,
189
237
  base: 8453
@@ -199,6 +247,17 @@ const CHARGE_INPUT_KEYS = {
199
247
  expiresIn: true,
200
248
  idempotencyKey: true
201
249
  };
250
+ const EMAIL_CHARGE_INPUT_KEYS = {
251
+ from: true,
252
+ to: true,
253
+ amount: true,
254
+ amountUsdc: true,
255
+ network: true,
256
+ description: true,
257
+ resource: true,
258
+ expiresIn: true,
259
+ idempotencyKey: true
260
+ };
202
261
  function usdcToBaseUnits(human) {
203
262
  const trimmed = human.trim();
204
263
  if (!/^\d+(\.\d+)?$/.test(trimmed)) return null;
@@ -235,13 +294,37 @@ function validateChallenge(c) {
235
294
  if (!c.expires_at) bad("expires_at");
236
295
  const nb = c.nonce_binding;
237
296
  if (!nb?.interaction_id || !nb.challenge_step_id || !nb.challenge_nonce) bad("nonce_binding");
238
- const pr = c.payment_requirements;
297
+ validatePaymentRequirements(c.payment_requirements, bad);
298
+ }
299
+ /** Validate the x402 PaymentRequirements shared by both challenge shapes. */
300
+ function validatePaymentRequirements(pr, bad) {
239
301
  if (!pr) bad("payment_requirements");
240
302
  if (!/^[1-9][0-9]{0,38}$/.test(pr.maxAmountRequired ?? "")) bad("payment_requirements.maxAmountRequired (expected a positive integer string in token base units)");
241
303
  if (!/^0x[0-9a-fA-F]{40}$/.test(pr.payTo ?? "")) bad("payment_requirements.payTo (expected a 0x address)");
242
304
  if (!/^0x[0-9a-fA-F]{40}$/.test(pr.asset ?? "")) bad("payment_requirements.asset (expected a 0x address)");
243
305
  if (!pr.extra?.name || !pr.extra.version) bad("payment_requirements.extra (name/version)");
244
306
  }
307
+ /**
308
+ * Assert an email-native challenge is fully hydrated before signing, so a
309
+ * missing field fails with a named X402Error instead of an opaque error
310
+ * mid-sign. The interaction_id and the challenge step id (the nonce binding's
311
+ * fields) drive both the bound nonce and the payment-step envelope, so they are
312
+ * checked here.
313
+ */
314
+ function validateEmailChallenge(c) {
315
+ const bad = (field) => {
316
+ throw new X402Error(`email challenge is missing or malformed: ${field}`, 0);
317
+ };
318
+ if (!c || typeof c !== "object") bad("email challenge");
319
+ if (!c.interaction_id) bad("interaction_id");
320
+ const ch = c.challenge;
321
+ if (!ch || typeof ch !== "object") bad("challenge");
322
+ if (!ch.expires_at) bad("challenge.expires_at");
323
+ const nb = ch.nonce_binding;
324
+ if (!nb?.interaction_id || !nb.challenge_step_id || !nb.challenge_nonce) bad("challenge.nonce_binding");
325
+ if (nb.interaction_id !== c.interaction_id) bad("interaction_id (mismatch with challenge.nonce_binding.interaction_id)");
326
+ validatePaymentRequirements(ch.payment_requirements, bad);
327
+ }
245
328
  var X402Client = class {
246
329
  #apiKey;
247
330
  #baseUrl;
@@ -301,6 +384,99 @@ var X402Client = class {
301
384
  return this.#request("POST", "/v1/x402/challenges", body, { headers: input.idempotencyKey ? { "idempotency-key": input.idempotencyKey } : void 0 });
302
385
  }
303
386
  /**
387
+ * Issue a payment challenge over an email thread (payee side). Sends the
388
+ * challenge as an email from `from` to `to` and binds the payment to that
389
+ * thread. Returns the challenge (including the real `interaction_id`); deliver
390
+ * it to the payer, who calls `payEmailChallenge` to build the signed payment.
391
+ *
392
+ * Provide exactly one of `amount` (base units) or `amountUsdc` (human USDC).
393
+ */
394
+ async createEmailChallenge(input) {
395
+ for (const key of Object.keys(input)) if (!(key in EMAIL_CHARGE_INPUT_KEYS)) throw new X402Error(`unknown createEmailChallenge() option "${key}"; expected one of: ${Object.keys(EMAIL_CHARGE_INPUT_KEYS).join(", ")}`, 0);
396
+ if (!input.from) throw new X402Error("createEmailChallenge() requires `from`", 0);
397
+ if (!input.to) throw new X402Error("createEmailChallenge() requires `to`", 0);
398
+ if (input.amount !== void 0 && input.amountUsdc !== void 0) throw new X402Error("createEmailChallenge() takes exactly one of `amount` (base units) or `amountUsdc` (human USDC), not both", 0);
399
+ const amount = input.amountUsdc !== void 0 ? usdcToBaseUnits(input.amountUsdc) : input.amount ?? null;
400
+ if (!amount || !/^[1-9][0-9]{0,38}$/.test(amount)) throw new X402Error("createEmailChallenge() requires `amount` as a positive integer string in token base units (e.g. \"10000\"), or `amountUsdc` as a positive USDC amount with at most 6 decimals (e.g. \"0.01\")", 0);
401
+ const body = {
402
+ from: input.from,
403
+ to: input.to,
404
+ amount,
405
+ network: input.network ?? "base-sepolia"
406
+ };
407
+ if (input.description) body.description = input.description;
408
+ if (input.resource) body.resource = input.resource;
409
+ if (input.expiresIn !== void 0) body.expires_in = input.expiresIn;
410
+ return this.#request("POST", "/v1/x402/email-challenges", body, { headers: input.idempotencyKey ? { "idempotency-key": input.idempotencyKey } : void 0 });
411
+ }
412
+ /**
413
+ * Build the signed payment step for an email-native challenge (payer side).
414
+ * Given a received `X402EmailChallenge` and the caller's signer, this derives
415
+ * the interaction-bound authorization, signs it locally, and returns the
416
+ * signed `interaction.json` payment-step envelope plus its canonical JSON
417
+ * bytes. It does NOT send anything.
418
+ *
419
+ * The caller sends `result.json` back as an `interaction.json` attachment on a
420
+ * reply to the challenge email (e.g. via the SDK's `send` / `reply`); the
421
+ * platform reads the envelope from those exact bytes, re-derives the bound
422
+ * nonce, and settles.
423
+ */
424
+ async payEmailChallenge(challenge, options) {
425
+ if (!options?.signer?.address || typeof options.signer.signTypedData !== "function") throw new X402Error("payEmailChallenge() requires options.signer with { address, signTypedData } (e.g. a viem LocalAccount)", 0);
426
+ validateEmailChallenge(challenge);
427
+ const details = challenge.challenge;
428
+ const pr = details.payment_requirements;
429
+ const network = pr.network;
430
+ const chainId = CHAIN_IDS[network];
431
+ if (chainId === void 0) throw new X402Error(`unsupported network: ${network}`, 0);
432
+ if (pr.scheme !== "exact") throw new X402Error(`unsupported payment scheme: ${pr.scheme}`, 0);
433
+ const nowSec = Math.floor(Date.now() / 1e3);
434
+ const expiresAtMs = Date.parse(details.expires_at);
435
+ if (Number.isNaN(expiresAtMs)) throw new X402Error(`challenge has an invalid expires_at: ${details.expires_at}`, 0);
436
+ const expiresAtSec = Math.floor(expiresAtMs / 1e3);
437
+ if (expiresAtSec <= nowSec) throw new X402Error(`challenge has already expired (expires_at ${details.expires_at}); not signing`, 0);
438
+ let validAfter;
439
+ let validBefore;
440
+ try {
441
+ ({validAfter, validBefore} = computePaymentValidityWindow({
442
+ challengeExpiresAtSec: expiresAtSec,
443
+ nowSec
444
+ }));
445
+ } catch (cause) {
446
+ throw new X402Error(cause instanceof Error ? cause.message : String(cause), 0, void 0, { cause });
447
+ }
448
+ const { authorization, signature } = await signInteractionPayment({
449
+ sign: (typedData) => options.signer.signTypedData(typedData),
450
+ payer: options.signer.address,
451
+ domain: {
452
+ name: pr.extra.name,
453
+ version: pr.extra.version,
454
+ chainId,
455
+ verifyingContract: pr.asset
456
+ },
457
+ payTo: pr.payTo,
458
+ amount: BigInt(pr.maxAmountRequired),
459
+ nonceBinding: {
460
+ interactionId: details.nonce_binding.interaction_id,
461
+ challengeStepId: details.nonce_binding.challenge_step_id,
462
+ challengeNonce: details.nonce_binding.challenge_nonce
463
+ },
464
+ validAfter,
465
+ validBefore
466
+ });
467
+ const payment = buildExactEvmPaymentPayload({
468
+ network,
469
+ authorization,
470
+ signature
471
+ });
472
+ return buildPaymentStepEnvelope({
473
+ interactionId: challenge.interaction_id,
474
+ stepId: randomUUID(),
475
+ prevStepId: details.nonce_binding.challenge_step_id,
476
+ payment
477
+ });
478
+ }
479
+ /**
304
480
  * Pay a challenge (payer side). Derives the interaction-bound authorization,
305
481
  * signs it locally with the caller's key, and submits it for settlement.
306
482
  */
@@ -423,4 +599,4 @@ function createX402Client(options = {}) {
423
599
  return new X402Client(options);
424
600
  }
425
601
  //#endregion
426
- export { DEFAULT_MAX_WINDOW_SEC, TRANSFER_WITH_AUTHORIZATION_TYPES, X402Client, X402Error, buildExactEvmPaymentPayload, buildPayoutRegistrationMessage, computePaymentValidityWindow, createX402Client, deriveEip3009Nonce, signInteractionPayment, toPaymentPayload, transferWithAuthorizationTypedData };
602
+ export { DEFAULT_MAX_WINDOW_SEC, TRANSFER_WITH_AUTHORIZATION_TYPES, X402Client, X402Error, X402_INTERACTION_PROTOCOL, X402_INTERACTION_PROTOCOL_VERSION, buildExactEvmPaymentPayload, buildPaymentStepEnvelope, buildPayoutRegistrationMessage, computePaymentValidityWindow, createX402Client, deriveEip3009Nonce, signInteractionPayment, toPaymentPayload, transferWithAuthorizationTypedData };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitivedotdev/sdk",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Official Primitive Node.js SDK: webhook, api, openapi, contract, and parser runtime modules.",
5
5
  "type": "module",
6
6
  "module": "./dist/index.js",
@@ -93,7 +93,7 @@
93
93
  "dependencies": {
94
94
  "ajv": "^8.17.1",
95
95
  "mailparser": "^3.9.0",
96
- "nodemailer": "^8.0.7",
96
+ "nodemailer": "^9.0.1",
97
97
  "sanitize-html": "^2.14.0",
98
98
  "tar-stream": "^3.1.8",
99
99
  "validator": "^13.15.35",