warpmetal 0.7.6 → 0.7.8

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.
package/README.md CHANGED
@@ -34,7 +34,7 @@ published wallet CLI with the exact version WarpMetal reports:
34
34
  npm install --global warpmetal
35
35
  warpmetal --help
36
36
 
37
- npm install --global @x402api/agent-wallet-cli@0.2.7
37
+ npm install --global @x402api/agent-wallet-cli@0.2.8
38
38
  x402api help --json
39
39
  ```
40
40
 
@@ -58,7 +58,7 @@ integration.
58
58
 
59
59
  The repository also contains a skills-only WarpMetal plugin for the public
60
60
  Plugins Directory shared by Codex and ChatGPT. The plugin remains a separate
61
- artifact from the npm CLI and requires `warpmetal` CLI version 0.7.6 or newer.
61
+ artifact from the npm CLI and requires `warpmetal` CLI version 0.7.8 or newer.
62
62
 
63
63
  To test the repository marketplace after the plugin lands on `main`:
64
64
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "warpmetal",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
4
4
  "description": "Agent-safe CLI and skill for purchasing, renewing, and managing WarpMetal VPS servers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,7 @@ with ad hoc HTTP commands.
11
11
 
12
12
  ## Start safely
13
13
 
14
- 1. Run `warpmetal --version` and require version `0.7.6` or newer for this
14
+ 1. Run `warpmetal --version` and require version `0.7.8` or newer for this
15
15
  plugin. If it is missing or older, explain the compatibility requirement,
16
16
  ask before installing or upgrading software, and use only the official npm
17
17
  package from `https://www.npmjs.com/package/warpmetal`.
@@ -136,12 +136,22 @@ WarpMetal verifies that the artifact matches the exact saved request and an
136
136
  advertised sponsored requirement, gas reservation, resource, extensions, and
137
137
  buyer payment identifier before sending its signature. The legacy
138
138
  `--payment-signature-file` input remains available for another compatible
139
- external signer. If a signed request returns a replacement challenge, use the
140
- new CLI-produced workflow and re-evaluate payment authority. Ask again when a
141
- human is chatting and the terms changed; in an unattended run, proceed only if
142
- the replacement remains within standing authority. On `manual_review` or an
139
+ external signer. If a signed request returns `payment_rejected`, preserve its
140
+ safe `errorCode` and `requestId` diagnostics and inspect `replacementAllowed`.
141
+ When it is true, run the unsigned challenge flow again and re-evaluate payment
142
+ authority before creating the one permitted replacement authorization. When it
143
+ is false or absent, stop without signing again. On `manual_review` or an
143
144
  ambiguous attempt, stop and never create another payment.
144
145
 
146
+ WarpMetal continues read-only backend reconciliation for
147
+ `manual_review/stale_payment_outcome`; this is not a client bypass. Re-read the
148
+ task on a later autonomous cycle. Only when it becomes `expired` with
149
+ `failure.code` equal to `payment_expired_unsettled`, `paidAt` null, and
150
+ `retrySafe` true has WarpMetal proved that the prior charge expired unpaid. An
151
+ autonomous workflow may then prepare a new order if the original intent and
152
+ standing authority still apply. Never reuse the old signature or infer safety
153
+ from wallet balance, timeout, `paidAt`, `expired`, or `manual_review` alone.
154
+
145
155
  ## Renew autonomously within policy
146
156
 
147
157
  Configure a bounded server policy before unattended renewal. Require a
@@ -178,6 +188,11 @@ Poll a prepared or paid order with:
178
188
  warpmetal order status --task <taskId> --wait --json
179
189
  ```
180
190
 
191
+ `manual_review` remains terminal for payment and mutation attempts. A later
192
+ read-only status check may observe the backend's authoritative reconciliation;
193
+ prepare a replacement only for the exact `retrySafe: true` result described
194
+ above.
195
+
181
196
  After initial provisioning and every OS reload, WarpMetal reapplies the same
182
197
  key-only policy: `PasswordAuthentication no`,
183
198
  `KbdInteractiveAuthentication no`, `PermitRootLogin prohibit-password`, and
@@ -64,12 +64,15 @@ suggested artifact paths live under the
64
64
  private WarpMetal state directory. An explicit output path must not already
65
65
  contain different content. `challengeHandle` is intentionally absent from the
66
66
  wallet envelope: it is neither a buyer payment identifier nor signing input.
67
+ `paymentChallengeDigest` is the distinct authoritative signing input forwarded
68
+ from x402api; the CLI fails closed instead of deriving it from
69
+ `PAYMENT-REQUIRED`.
67
70
  If a previously issued unsigned sponsorship reservation expired, WarpMetal may
68
71
  retire that attempt and return a new `paymentAttemptId`. The CLI replaces the
69
72
  saved challenge and stale wallet-attempt metadata; use only the newly returned
70
73
  workflow.
71
74
 
72
- The current integration targets `@x402api/agent-wallet-cli@0.2.7`. A compatible
75
+ The current integration targets `@x402api/agent-wallet-cli@0.2.8`. A compatible
73
76
  live term is marked `agentWalletSupported: true` and must use the sponsored
74
77
  Base USDC or Solana USDC/USDT launch profile with buyer native fees disabled.
75
78
  For the current declaration, x402api pays actual gas from its platform treasury
@@ -25,7 +25,7 @@ contract.
25
25
  WarpMetal runs on Node.js 20 or 22. The x402api Agent Wallet currently requires
26
26
  Node.js 22. Use the exact published package reported by
27
27
  `paymentWorkflow.signerPackage.spec`; the current contract is
28
- `@x402api/agent-wallet-cli@0.2.7`. Do not add it as a WarpMetal dependency,
28
+ `@x402api/agent-wallet-cli@0.2.8`. Do not add it as a WarpMetal dependency,
29
29
  install executable wallet code from an unpinned repository URL, or substitute
30
30
  a similarly named package.
31
31
 
@@ -140,6 +140,9 @@ also contains `challengeHandle`, an opaque value retained by WarpMetal for
140
140
  merchant reconciliation. It is deliberately excluded from the x402api V1
141
141
  request envelope and must not be confused with the wallet-created buyer payment
142
142
  identifier.
143
+ The wallet nonce is bound to the authoritative `paymentChallengeDigest` that
144
+ WarpMetal forwards from x402api's charge response. Never derive or replace that
145
+ digest with a hash of `PAYMENT-REQUIRED`.
143
146
 
144
147
  Choose only a term marked `agentWalletSupported: true`,
145
148
  `sponsoredNetworkFee: true`, and `buyerNativeFeeRequired: false`. The supported
@@ -149,7 +152,7 @@ reservation or any buyer-funded, unsupported, or unbound alternative.
149
152
 
150
153
  x402api pays actual gas from its platform treasury. The merchant tenant's
151
154
  active sponsorship allowance controls admission, but actual gas is not a
152
- tenant debit. During the coordinated rollout, accept only the exact 0.2.7
155
+ tenant debit. During the coordinated rollout, accept only the exact 0.2.8
153
156
  platform-treasury declaration or the matched legacy tenant-credit declaration;
154
157
  never accept a mixed billing and final-charge policy.
155
158
 
@@ -196,10 +199,12 @@ current `termEndsAt` generation are different.
196
199
  - Timeout or process restart after authorization: use the saved x402api attempt
197
200
  ID and artifact. Reconcile with WarpMetal and reuse its submit argv. Never
198
201
  authorize again merely because submission is unknown.
199
- - Signed HTTP 402: the artifact was definitively rejected. Preserve its
200
- `paymentId`, then run the WarpMetal challenge flow again, inspect the replacement terms, and authorize one new
201
- artifact only when the protocol and current interactive or autonomous
202
- payment authority allow it.
202
+ - Signed HTTP 402: the artifact was definitively rejected. A `paymentId` may be
203
+ absent because settlement never began; preserve the safe `errorCode`,
204
+ `requestId`, and `replacementAllowed` result instead. Only when
205
+ `replacementAllowed` is true may you run the unsigned WarpMetal challenge
206
+ flow again, inspect the replacement terms, and authorize the one permitted
207
+ replacement artifact. When false or absent, stop without signing again.
203
208
  - `sponsorship_allowance_unavailable`, `sponsorship_payment_cap_exceeded`,
204
209
  `sponsorship_payment_allowance_exhausted`,
205
210
  `sponsorship_volume_allowance_exhausted`, or
@@ -207,7 +212,12 @@ current `termEndsAt` generation are different.
207
212
  terminal. The merchant tenant must restore or change its allowance and issue
208
213
  a fresh challenge; never retry the artifact or fall back to buyer-funded gas.
209
214
  - `manual_review`: payment or fulfillment may be final. Stop all payment and
210
- mutation retries.
215
+ mutation retries. WarpMetal may continue read-only backend reconciliation for
216
+ `stale_payment_outcome`; check status on a later autonomous cycle without
217
+ submitting a payment. Only `expired` plus
218
+ `failure.code: payment_expired_unsettled`, `paidAt: null`, and
219
+ `retrySafe: true` proves the old charge expired unpaid and permits preparing
220
+ a new order under current standing authority. Never reuse the old signature.
211
221
  - Expired or corrupt signed artifact, changed request digest, unexpected recipient,
212
222
  unsupported network, asset, profile, sponsorship error, or request-binding
213
223
  mismatch: stop instead of falling back or asking the buyer to fund ETH/SOL.
@@ -127,7 +127,9 @@ generated QR encoding only that address.
127
127
  ## Stop and recovery states
128
128
 
129
129
  - Insufficient balance: notify once, stop, and recheck later.
130
- - Signed HTTP 402: re-run prepare and re-evaluate the replacement challenge.
130
+ - Signed HTTP 402: preserve the safe rejection diagnostics. Re-run prepare and
131
+ re-evaluate one replacement challenge only when `replacementAllowed` is true;
132
+ stop when false or absent.
131
133
  - Pending or timeout after authorization: reuse the same artifact; do not sign again.
132
134
  - Price or payment rail outside policy: stop for deliberate policy revision.
133
135
  - Exhausted count, horizon, or cumulative budget: stop for deliberate policy revision.
@@ -117,9 +117,11 @@ Confirm the plan, hostname, OS, and public key before preparing it.
117
117
  - For `payment_pending`, retry the exact checkout body and the exact same
118
118
  payment artifact. Preserve the returned `paymentId` as the durable x402api
119
119
  reconciliation key. Do not create a replacement payment.
120
- - A signed HTTP 402 rejects that signature. Preserve its `paymentId`, request a
121
- new live challenge, and create one replacement authorization after checking the new terms against the current
122
- interactive or autonomous payment authority.
120
+ - A signed HTTP 402 rejects that signature. A `paymentId` can be absent;
121
+ preserve the safe `errorCode`, `requestId`, and `replacementAllowed` result.
122
+ Request a new live challenge and create the one permitted replacement only
123
+ when `replacementAllowed` is true and the new terms remain within current
124
+ interactive or autonomous payment authority. When false or absent, stop.
123
125
  - Treat `sponsorship_allowance_unavailable`,
124
126
  `sponsorship_payment_cap_exceeded`,
125
127
  `sponsorship_payment_allowance_exhausted`,
@@ -131,8 +133,11 @@ Confirm the plan, hostname, OS, and public key before preparing it.
131
133
  attempt and artifact through WarpMetal. Never authorize again merely because
132
134
  submission or fulfillment is uncertain, and never send the private owner
133
135
  token through `x402api pay`, `payment submit`, or `payment reconcile`.
134
- - Treat `manual_review` as terminal. Do not pay again and do not repeat the
135
- mutation.
136
+ - Treat `manual_review` as terminal for payments and mutations. Do not pay
137
+ again and do not repeat the mutation. A later read-only status check may
138
+ observe automatic reconciliation of `stale_payment_outcome`; only the exact
139
+ `expired/payment_expired_unsettled` result with `paidAt: null` and
140
+ `retrySafe: true` permits preparing a new order.
136
141
  - Treat HTTP 202 as accepted or pending, never as proof of success. Poll the
137
142
  returned task or operation until a documented terminal state.
138
143
  - Temporary sandbox expiry is a local hard deadline. Stop, restart, and
package/src/cli.js CHANGED
@@ -385,8 +385,23 @@ function challengeResult(
385
385
  response,
386
386
  { requireChallenge = true } = {},
387
387
  ) {
388
+ const status = response.data?.status;
389
+ const rejected = response.status === 402 && status === "payment_rejected";
388
390
  const paymentRequired = response.headers["payment-required"];
389
391
  const challengeHandle = response.headers["x-x402api-challenge-handle"];
392
+ const headerChallengeDigest =
393
+ response.headers["x-x402api-challenge-digest"];
394
+ const bodyChallengeDigest = response.data?.challengeDigest;
395
+ if (
396
+ headerChallengeDigest &&
397
+ bodyChallengeDigest &&
398
+ headerChallengeDigest !== bodyChallengeDigest
399
+ ) {
400
+ throw new CliError(
401
+ "WarpMetal returned conflicting x402 challenge digests.",
402
+ );
403
+ }
404
+ const challengeDigest = bodyChallengeDigest || headerChallengeDigest;
390
405
  if (requireChallenge && response.status === 402 && !paymentRequired) {
391
406
  throw new CliError("WarpMetal returned HTTP 402 without PAYMENT-REQUIRED.");
392
407
  }
@@ -395,15 +410,28 @@ function challengeResult(
395
410
  "WarpMetal returned HTTP 402 without X-X402API-Challenge-Handle.",
396
411
  );
397
412
  }
413
+ if (paymentRequired && !challengeDigest) {
414
+ throw new CliError(
415
+ "WarpMetal returned PAYMENT-REQUIRED without X-X402API-Challenge-Digest.",
416
+ );
417
+ }
398
418
  return {
399
- status: response.data?.status,
419
+ status,
400
420
  taskId,
401
421
  paymentAttemptId:
402
422
  response.data?.paymentAttemptId ||
403
423
  response.headers["x-warpmetal-payment-attempt"],
404
424
  paymentRequired,
405
425
  challengeHandle,
426
+ challengeDigest,
406
427
  checkoutBodySha256: createHash("sha256").update(checkoutBody).digest("hex"),
428
+ ...(rejected
429
+ ? {
430
+ errorCode: response.data?.errorCode,
431
+ requestId: response.data?.requestId,
432
+ replacementAllowed: response.data?.replacementAllowed,
433
+ }
434
+ : {}),
407
435
  };
408
436
  }
409
437
 
@@ -421,6 +449,7 @@ async function attachPaymentWorkflow(
421
449
  checkoutPath: order.checkoutPath,
422
450
  checkoutBody: order.checkoutBody,
423
451
  paymentRequired: safe.paymentRequired,
452
+ challengeDigest: safe.challengeDigest,
424
453
  merchantReference: taskId,
425
454
  });
426
455
  const defaults = defaultPaymentPaths(
@@ -718,6 +747,7 @@ async function handleCheckoutSubmit(client, store, options, context) {
718
747
  checkoutPath: order.checkoutPath,
719
748
  checkoutBody: order.checkoutBody,
720
749
  paymentRequired: order.paymentRequired,
750
+ challengeDigest: order.paymentChallengeDigest,
721
751
  merchantReference: taskId,
722
752
  });
723
753
  walletPayment = await readPaymentArtifact(artifactFile, expected);
@@ -753,6 +783,14 @@ async function handleCheckoutSubmit(client, store, options, context) {
753
783
  requireChallenge: false,
754
784
  }),
755
785
  );
786
+ if (response.status === 402 && safe.status === "payment_rejected") {
787
+ await store.saveOrderPaymentRejection(taskId, {
788
+ paymentAttemptId: safe.paymentAttemptId,
789
+ errorCode: safe.errorCode,
790
+ requestId: safe.requestId,
791
+ replacementAllowed: safe.replacementAllowed,
792
+ });
793
+ }
756
794
  const retryable =
757
795
  response.status === 202 &&
758
796
  ["payment_pending", "payment_finalizing"].includes(response.data?.status);
@@ -1026,6 +1064,7 @@ async function attachRenewalPaymentWorkflow(
1026
1064
  checkoutPath,
1027
1065
  checkoutBody: bodyText,
1028
1066
  paymentRequired: safe.paymentRequired,
1067
+ challengeDigest: safe.challengeDigest,
1029
1068
  merchantReference: `renew:${serverId}:${server.termEndsAt || "unset"}`,
1030
1069
  });
1031
1070
  const matchingTerms = request.terms.filter(
@@ -1300,6 +1339,7 @@ async function handleRenewalSubmit(client, store, options, context) {
1300
1339
  checkoutPath: renewal.checkoutPath,
1301
1340
  checkoutBody: renewal.checkoutBody,
1302
1341
  paymentRequired: renewal.paymentRequired,
1342
+ challengeDigest: renewal.paymentChallengeDigest,
1303
1343
  merchantReference: `renew:${serverId}:${renewal.termEndsAt || "unset"}`,
1304
1344
  });
1305
1345
  const walletPayment = await readPaymentArtifact(artifactFile, expected);
@@ -1341,6 +1381,16 @@ async function handleRenewalSubmit(client, store, options, context) {
1341
1381
  if (response.data?.paymentId) {
1342
1382
  await store.saveRenewalPaymentId(serverId, response.data.paymentId);
1343
1383
  }
1384
+ if (response.status === 402 && response.data?.status === "payment_rejected") {
1385
+ await store.saveRenewalPaymentRejection(serverId, {
1386
+ paymentAttemptId:
1387
+ response.data?.paymentAttemptId ||
1388
+ response.headers["x-warpmetal-payment-attempt"],
1389
+ errorCode: response.data?.errorCode,
1390
+ requestId: response.data?.requestId,
1391
+ replacementAllowed: response.data?.replacementAllowed,
1392
+ });
1393
+ }
1344
1394
  const retryable =
1345
1395
  response.status === 202 &&
1346
1396
  ["payment_pending", "payment_finalizing"].includes(response.data?.status);
@@ -1357,6 +1407,9 @@ async function handleRenewalSubmit(client, store, options, context) {
1357
1407
  paymentAttemptId:
1358
1408
  response.data?.paymentAttemptId ||
1359
1409
  response.headers["x-warpmetal-payment-attempt"],
1410
+ errorCode: response.data?.errorCode,
1411
+ requestId: response.data?.requestId,
1412
+ replacementAllowed: response.data?.replacementAllowed,
1360
1413
  walletPayment: {
1361
1414
  attemptId: walletPayment.attemptId,
1362
1415
  wallet: walletPayment.wallet,
package/src/payment.js CHANGED
@@ -15,7 +15,7 @@ const MAX_ARTIFACT_BYTES = 1024 * 1024;
15
15
  const MAX_SIGNATURE_BYTES = 512 * 1024;
16
16
 
17
17
  export const AGENT_WALLET_PACKAGE = "@x402api/agent-wallet-cli";
18
- export const AGENT_WALLET_VERSION = "0.2.7";
18
+ export const AGENT_WALLET_VERSION = "0.2.8";
19
19
  const AGENT_WALLET_SPEC = `${AGENT_WALLET_PACKAGE}@${AGENT_WALLET_VERSION}`;
20
20
  const GAS_SPONSORSHIP_EXTENSION = "com.x402api.gas-sponsorship";
21
21
  const BASE_NETWORK = "eip155:8453";
@@ -399,7 +399,7 @@ export function parsePaymentRequired(value, expectedUrl) {
399
399
  const sponsorship = extensions[GAS_SPONSORSHIP_EXTENSION]?.info;
400
400
  return {
401
401
  challenge: parsed,
402
- challengeDigest: digestJson(parsed),
402
+ paymentRequiredDigest: digestJson(parsed),
403
403
  accepts,
404
404
  sponsorshipExpiresAt: sponsorship?.expiresAt,
405
405
  terms: accepts.map((requirement) => {
@@ -434,6 +434,7 @@ export function createPaymentRequestEnvelope({
434
434
  checkoutPath,
435
435
  checkoutBody,
436
436
  paymentRequired,
437
+ challengeDigest,
437
438
  merchantReference,
438
439
  }) {
439
440
  const url = new URL(
@@ -441,6 +442,14 @@ export function createPaymentRequestEnvelope({
441
442
  `${baseUrl.replace(/\/$/, "")}/`,
442
443
  ).toString();
443
444
  const parsed = parsePaymentRequired(paymentRequired, url);
445
+ if (typeof challengeDigest !== "string" || !SHA256.test(challengeDigest)) {
446
+ throw invalid("The authoritative x402 challenge digest is malformed.");
447
+ }
448
+ if (challengeDigest === parsed.paymentRequiredDigest) {
449
+ throw invalid(
450
+ "The x402 challenge digest was derived from PAYMENT-REQUIRED instead of the authoritative charge response.",
451
+ );
452
+ }
444
453
  const supportedTerms = parsed.terms.filter(
445
454
  (term) => term.agentWalletSupported,
446
455
  );
@@ -466,13 +475,13 @@ export function createPaymentRequestEnvelope({
466
475
  contentType: "application/json",
467
476
  bodyBase64: Buffer.from(checkoutBody, "utf8").toString("base64"),
468
477
  paymentRequired,
469
- challengeDigest: parsed.challengeDigest,
478
+ challengeDigest,
470
479
  merchantReference,
471
480
  };
472
481
  return {
473
482
  envelope,
474
483
  requestDigest: digestJson(envelope),
475
- challengeDigest: parsed.challengeDigest,
484
+ challengeDigest,
476
485
  challenge: parsed.challenge,
477
486
  sponsorshipExpiresAt: parsed.sponsorshipExpiresAt,
478
487
  requirementDigests: new Set(
package/src/state.js CHANGED
@@ -14,9 +14,60 @@ const WALLET_PAYMENT_STATE_FIELDS = [
14
14
  "paymentArtifactExpiresAt",
15
15
  "paymentArtifactSavedAt",
16
16
  "gatewayPaymentId",
17
+ "rejectedPaymentAttemptId",
18
+ "paymentRejectionErrorCode",
19
+ "paymentRejectionRequestId",
20
+ "paymentReplacementAllowed",
21
+ "paymentRejectedAt",
17
22
  ];
18
23
  const X402API_PAYMENT_ID =
19
24
  /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
25
+ const SAFE_PROTOCOL_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
26
+
27
+ function validatedPaymentRejection({
28
+ paymentAttemptId,
29
+ errorCode,
30
+ requestId,
31
+ replacementAllowed,
32
+ }) {
33
+ if (
34
+ typeof paymentAttemptId !== "string" ||
35
+ !SAFE_PROTOCOL_IDENTIFIER.test(paymentAttemptId)
36
+ ) {
37
+ throw new CliError("WarpMetal returned an invalid payment-attempt ID.", {
38
+ exitCode: 3,
39
+ });
40
+ }
41
+ for (const [label, value] of [
42
+ ["payment rejection error code", errorCode],
43
+ ["payment rejection request ID", requestId],
44
+ ]) {
45
+ if (
46
+ value !== undefined &&
47
+ (typeof value !== "string" || !SAFE_PROTOCOL_IDENTIFIER.test(value))
48
+ ) {
49
+ throw new CliError(`WarpMetal returned an invalid ${label}.`, {
50
+ exitCode: 3,
51
+ });
52
+ }
53
+ }
54
+ if (
55
+ replacementAllowed !== undefined &&
56
+ typeof replacementAllowed !== "boolean"
57
+ ) {
58
+ throw new CliError(
59
+ "WarpMetal returned an invalid payment replacement decision.",
60
+ { exitCode: 3 },
61
+ );
62
+ }
63
+ return {
64
+ rejectedPaymentAttemptId: paymentAttemptId,
65
+ paymentRejectionErrorCode: errorCode,
66
+ paymentRejectionRequestId: requestId,
67
+ paymentReplacementAllowed: replacementAllowed,
68
+ paymentRejectedAt: new Date().toISOString(),
69
+ };
70
+ }
20
71
 
21
72
  function replaceChallenge(record, challenge) {
22
73
  const changed =
@@ -386,6 +437,28 @@ export class StateStore {
386
437
  });
387
438
  }
388
439
 
440
+ async saveOrderPaymentRejection(taskId, rejection) {
441
+ const safe = validatedPaymentRejection(rejection);
442
+ await this.update((state) => {
443
+ const order = state.orders[taskId];
444
+ if (!order) {
445
+ throw new CliError(`No local order state exists for ${taskId}.`, {
446
+ exitCode: 2,
447
+ });
448
+ }
449
+ if (
450
+ order.paymentAttemptId &&
451
+ order.paymentAttemptId !== safe.rejectedPaymentAttemptId
452
+ ) {
453
+ throw new CliError(
454
+ "WarpMetal changed the payment-attempt ID for this authorization.",
455
+ { exitCode: 3 },
456
+ );
457
+ }
458
+ Object.assign(order, safe);
459
+ });
460
+ }
461
+
389
462
  async saveRenewalPaymentId(serverId, paymentId) {
390
463
  if (!X402API_PAYMENT_ID.test(paymentId || "")) {
391
464
  throw new CliError("WarpMetal returned an invalid x402api payment ID.", {
@@ -408,6 +481,28 @@ export class StateStore {
408
481
  });
409
482
  }
410
483
 
484
+ async saveRenewalPaymentRejection(serverId, rejection) {
485
+ const safe = validatedPaymentRejection(rejection);
486
+ await this.update((state) => {
487
+ const renewal = state.renewals[serverId];
488
+ if (!renewal) {
489
+ throw new CliError(`No local renewal state exists for ${serverId}.`, {
490
+ exitCode: 2,
491
+ });
492
+ }
493
+ if (
494
+ renewal.paymentAttemptId &&
495
+ renewal.paymentAttemptId !== safe.rejectedPaymentAttemptId
496
+ ) {
497
+ throw new CliError(
498
+ "WarpMetal changed the payment-attempt ID for this renewal authorization.",
499
+ { exitCode: 3 },
500
+ );
501
+ }
502
+ Object.assign(renewal, safe);
503
+ });
504
+ }
505
+
411
506
  async saveAccessToken(serverId, accessToken, expiresAt) {
412
507
  await this.update((state) => {
413
508
  const server = state.servers[serverId] || { serverId };
@@ -541,6 +636,10 @@ export class StateStore {
541
636
  walletName: order.walletName,
542
637
  payerAddress: order.walletPayerAddress,
543
638
  paymentArtifactExpiresAt: order.paymentArtifactExpiresAt,
639
+ rejectedPaymentAttemptId: order.rejectedPaymentAttemptId,
640
+ paymentRejectionErrorCode: order.paymentRejectionErrorCode,
641
+ paymentRejectionRequestId: order.paymentRejectionRequestId,
642
+ paymentReplacementAllowed: order.paymentReplacementAllowed,
544
643
  credentialStored: Boolean(order.ownerToken),
545
644
  })),
546
645
  servers: Object.values(state.servers).map((server) => ({
@@ -574,6 +673,10 @@ export class StateStore {
574
673
  paymentId: renewal.gatewayPaymentId,
575
674
  challengeHandle: renewal.challengeHandle,
576
675
  paymentArtifactExpiresAt: renewal.paymentArtifactExpiresAt,
676
+ rejectedPaymentAttemptId: renewal.rejectedPaymentAttemptId,
677
+ paymentRejectionErrorCode: renewal.paymentRejectionErrorCode,
678
+ paymentRejectionRequestId: renewal.paymentRejectionRequestId,
679
+ paymentReplacementAllowed: renewal.paymentReplacementAllowed,
577
680
  })),
578
681
  };
579
682
  }