warpmetal 0.5.0 → 0.7.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.
package/README.md CHANGED
@@ -2,14 +2,13 @@
2
2
 
3
3
  The official command-line client and portable Agent Skill for WarpMetal.
4
4
 
5
- The CLI uses the public API at `https://api.warpmetal.com`, stores generated
6
- WarpMetal credentials in a user-private state file, and never reads or stores
7
- wallet private keys or SSH private-key contents. Version 0.5 adds dedicated
8
- hostname-based VPS identities, bounded autonomous renewal, verified lifecycle
9
- email, and signed refill requests through the x402api Agent Wallet. WarpMetal
10
- writes the exact credential-free payment request, explains the next commands to
11
- an agent, validates the returned payment artifact, and submits it without
12
- absorbing wallet custody.
5
+ The CLI uses `https://api.warpmetal.com` as the API root; endpoints begin at
6
+ `/health`, `/catalog`, `/orders`, and so on, without a second `/api` prefix. It
7
+ stores generated WarpMetal credentials in a user-private state file and never
8
+ reads or stores wallet private keys or SSH private-key contents. Version 0.6
9
+ adopts this canonical root-path API. WarpMetal writes the exact credential-free
10
+ payment request, explains the next commands to an agent, validates the returned
11
+ payment artifact, and submits it without absorbing wallet custody.
13
12
 
14
13
  ## Distribution
15
14
 
@@ -33,7 +32,7 @@ published wallet CLI with the exact version WarpMetal reports:
33
32
  npm install --global warpmetal
34
33
  warpmetal --help
35
34
 
36
- npm install --global @x402api/agent-wallet-cli@0.2.1
35
+ npm install --global @x402api/agent-wallet-cli@0.2.2
37
36
  x402api help --json
38
37
  ```
39
38
 
@@ -86,10 +85,13 @@ After preparing an order, request its live payment challenge:
86
85
  warpmetal checkout challenge --task <taskId> --json
87
86
  ```
88
87
 
89
- On HTTP 402 the CLI returns exact `paymentTerms`, the pinned
88
+ On HTTP 402 the CLI returns exact `paymentTerms`, the opaque `challengeHandle`
89
+ that WarpMetal uses for merchant-side reconciliation, the pinned
90
90
  `@x402api/agent-wallet-cli` package contract, and argv arrays under
91
91
  `paymentWorkflow`. It also writes an owner-only request envelope that contains
92
- the exact checkout URL and body but no WarpMetal credential. The published
92
+ the exact checkout URL and body but no WarpMetal credential or challenge
93
+ handle. The handle is not a buyer payment identifier and is never a wallet
94
+ signing input. The published
93
95
  launch wallet accepts sponsored Base USDC and sponsored Solana USDC/USDT only;
94
96
  the returned terms identify compatible alternatives and confirm that the buyer
95
97
  does not need ETH or SOL. Payment authority depends on execution context:
@@ -142,11 +144,13 @@ after authorization.
142
144
 
143
145
  If funding is short in an interactive conversation, tell the human the exact
144
146
  top-up in normal and atomic units, the network, stablecoin and contract/mint,
145
- and the payer wallet's public receiving address. The human sends the token to
146
- that wallet address, never to the token contract/mint or WarpMetal's payment
147
- recipient, and never sends ETH or SOL for a sponsored payment. In an unattended
148
- run, use a preconfigured refill or escalation mechanism or stop with
149
- `funding_required`.
147
+ and the payer wallet's public receiving address. The returned
148
+ `paymentWorkflow.fundingWorkflow` provides safe address and balance argv plus a
149
+ presentation contract: render that public address as both a QR code and
150
+ copyable text. The human sends the token to that wallet address, never to the
151
+ token contract/mint or WarpMetal's payment recipient, and never sends ETH or
152
+ SOL for a sponsored payment. In an unattended run, use a preconfigured refill
153
+ or escalation mechanism or stop with `funding_required`.
150
154
 
151
155
  ## Autonomous renewal and refill
152
156
 
@@ -169,7 +173,11 @@ warpmetal renewal configure \
169
173
  ```
170
174
 
171
175
  The recipient must follow the one-time verification link before lifecycle or
172
- refill mail is sent. A recurring unattended agent can then run:
176
+ refill mail is sent. If no verified notification email exists, `renewal
177
+ configure` returns `email_required` before changing policy. Supply `--email`,
178
+ or deliberately continue with `--without-email-notifications`; the latter does
179
+ not enable signed refill-email workflows. A recurring unattended agent can then
180
+ run:
173
181
 
174
182
  ```sh
175
183
  warpmetal renewal due --all --json
@@ -177,14 +185,16 @@ warpmetal renewal run --all-due --json
177
185
  ```
178
186
 
179
187
  Inside policy, the CLI returns the exact Agent Wallet authorization and submit
180
- argv. If balance is insufficient, run the returned `refillWorkflow.argv` with
181
- its `X402API_NOTIFICATION_URL` environment value. `x402api wallet
188
+ argv. If balance is insufficient, `refillWorkflow` is returned only when the
189
+ server has an active verified notification subscription. Run its argv with the
190
+ returned `X402API_NOTIFICATION_URL` environment value. `x402api wallet
182
191
  notify-refill` signs an opaque subscription reference and wallet-produced
183
192
  balance fields; it cannot choose an email address. WarpMetal verifies the
184
193
  wallet signature and current on-chain balance before emailing the verified
185
- human the network, stablecoin, public wallet address, and required minimum
186
- top-up. The human may transfer more than that minimum; the renewal policy—not
187
- the refill target—remains the spending authority.
194
+ human the network, stablecoin, public wallet address, required minimum top-up,
195
+ and a locally generated QR encoding only that wallet address. The address is
196
+ also repeated as copyable text. The human may transfer more than that minimum;
197
+ the renewal policy—not the refill target—remains the spending authority.
188
198
 
189
199
  The agent never sends a partial x402 payment. If no verified refill path
190
200
  exists, it reports `funding_required`. If a previous payment is pending or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "warpmetal",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Agent-safe CLI and skill for purchasing, renewing, and managing WarpMetal VPS servers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -111,7 +111,9 @@ task or operator limit.
111
111
  After interactive confirmation or autonomous policy validation, invoke the
112
112
  returned authorize argv once. The separate
113
113
  `x402api` executable owns the wallet, validates the envelope, and writes the
114
- payment artifact. Then invoke the returned submit argv, equivalent to:
114
+ payment artifact. The returned `challengeHandle` is opaque merchant
115
+ reconciliation metadata; never copy it into the request envelope or treat it as
116
+ the buyer payment identifier. Then invoke the returned submit argv, equivalent to:
115
117
 
116
118
  ```sh
117
119
  warpmetal checkout submit \
@@ -152,9 +154,12 @@ price, asset, network, count, horizon, or total-budget mismatch.
152
154
  Run `warpmetal renewal prepare --server <serverId> --json`, then invoke only
153
155
  the returned `paymentWorkflow.authorize.argv` and
154
156
  `paymentWorkflow.submit.argv`. If authorization reports insufficient balance,
155
- set the returned `refillWorkflow.environment`, invoke its exact argv once, and
156
- stop until funding arrives. The signed refill intent resolves to a verified
157
- human contact; never add an email address to it. Never make a partial payment.
157
+ use `paymentWorkflow.fundingWorkflow` to obtain the public address and balance,
158
+ then show the address as both a QR code and copyable text. Only when
159
+ `refillNotification.available` is true, set the returned
160
+ `refillWorkflow.environment`, invoke its exact argv once, and stop until
161
+ funding arrives. The signed refill intent resolves to a verified human contact;
162
+ never add an email address to it. Never make a partial payment.
158
163
 
159
164
  After funding, prepare again, authorize exactly once, submit with WarpMetal,
160
165
  and confirm the returned `termEndsAt`. On `reconcile_pending` or
@@ -53,14 +53,16 @@ warpmetal order status \
53
53
  ```
54
54
 
55
55
  On HTTP 402, `checkout challenge` validates and displays exact payment terms,
56
- writes a credential-free x402api V1 request envelope with owner-only
56
+ writes the opaque x402api `challengeHandle` to private WarpMetal state for
57
+ merchant reconciliation, and writes a credential-free x402api V1 request envelope with owner-only
57
58
  permissions, and returns the exact pinned wallet package, V1 contract probe,
58
59
  matching wallet-skill install, authorization, and WarpMetal submission argv
59
60
  arrays. The default envelope and suggested artifact paths live under the
60
61
  private WarpMetal state directory. An explicit output path must not already
61
- contain different content.
62
+ contain different content. `challengeHandle` is intentionally absent from the
63
+ wallet envelope: it is neither a buyer payment identifier nor signing input.
62
64
 
63
- The current integration targets `@x402api/agent-wallet-cli@0.2.1`. A compatible
65
+ The current integration targets `@x402api/agent-wallet-cli@0.2.2`. A compatible
64
66
  live term is marked `agentWalletSupported: true` and must use the sponsored
65
67
  Base USDC or Solana USDC/USDT launch profile with buyer native fees disabled.
66
68
  WarpMetal rejects a challenge with no compatible sponsored term. Because the
@@ -101,7 +103,8 @@ warpmetal renewal configure \
101
103
  (--maximum-renewals <n> | --renew-through <UTC>) \
102
104
  [--maximum-total-spend-atomic <amount>] \
103
105
  --allowed-network <network> --allowed-asset <asset> \
104
- --wallet <name> [--refill-target-atomic <amount>] [--email <address>] --json
106
+ --wallet <name> [--refill-target-atomic <amount>] \
107
+ [--email <address> | --without-email-notifications] --json
105
108
  warpmetal renewal status --server <serverId> --json
106
109
  warpmetal renewal due (--server <serverId> | --all) --json
107
110
  warpmetal renewal prepare --server <serverId> --json
@@ -113,9 +116,12 @@ warpmetal notifications configure --server <serverId> --email <address> \
113
116
  warpmetal notifications status --server <serverId> --json
114
117
  ```
115
118
 
116
- `renewal prepare` returns exact payment and refill argv arrays. `renewal run`
117
- is an agent-facing state machine, not a wallet-signing daemon. See
118
- [renewals.md](renewals.md).
119
+ Without a verified notification email, renewal configuration asks for an email
120
+ before mutating policy unless `--without-email-notifications` explicitly opts
121
+ out. `renewal prepare` always returns safe funding address/balance argv and
122
+ returns signed refill-email argv only for a verified active subscription.
123
+ `renewal run` is an agent-facing state machine, not a wallet-signing daemon.
124
+ See [renewals.md](renewals.md).
119
125
 
120
126
  ## Server management
121
127
 
@@ -19,7 +19,7 @@ files or pass a WarpMetal credential to `x402api`.
19
19
  WarpMetal runs on Node.js 20 or 22. The x402api Agent Wallet currently requires
20
20
  Node.js 22. Use the exact published package reported by
21
21
  `paymentWorkflow.signerPackage.spec`; the current contract is
22
- `@x402api/agent-wallet-cli@0.2.1`. Do not add it as a WarpMetal dependency,
22
+ `@x402api/agent-wallet-cli@0.2.2`. Do not add it as a WarpMetal dependency,
23
23
  install executable wallet code from an unpinned repository URL, or substitute
24
24
  a similarly named package.
25
25
 
@@ -66,10 +66,12 @@ skill automatically.
66
66
 
67
67
  6. If funding is short in an interactive conversation, show the exact deficit
68
68
  in normal token units and atomic units, the exact network, token symbol and
69
- contract/mint, and the payer wallet's public receiving address. Tell the
70
- human to transfer the token to the payer wallet address, never to the token
71
- contract/mint or `paymentTerms.recipient`. Sponsored launch payments never
72
- ask the buyer to fund ETH or SOL. Recheck the balance after the transfer.
69
+ contract/mint, and the payer wallet's public receiving address. Render only
70
+ that public address as a QR code and repeat the full address as copyable
71
+ text. Tell the human to transfer the token to the payer wallet address,
72
+ never to the token contract/mint or `paymentTerms.recipient`. Sponsored
73
+ launch payments never ask the buyer to fund ETH or SOL. Recheck the balance
74
+ after the transfer.
73
75
  7. If funding is short in an unattended run, use only a preconfigured refill
74
76
  mechanism returned by WarpMetal. For a configured renewal, set the returned
75
77
  `refillWorkflow.environment`, invoke `refillWorkflow.argv` once, and stop
@@ -114,7 +116,11 @@ paymentWorkflow.submit.argv
114
116
  Replace only `<wallet-name>` after selecting the wallet for the advertised
115
117
  network. Do not parse, rewrite, copy, or display the request envelope or payment
116
118
  artifact. The envelope excludes the WarpMetal owner token; the artifact holds
117
- the complete payment signature and remains owner-only.
119
+ the complete payment signature and remains owner-only. The safe challenge JSON
120
+ also contains `challengeHandle`, an opaque value retained by WarpMetal for
121
+ merchant reconciliation. It is deliberately excluded from the x402api V1
122
+ request envelope and must not be confused with the wallet-created buyer payment
123
+ identifier.
118
124
 
119
125
  Choose only a term marked `agentWalletSupported: true`,
120
126
  `sponsoredNetworkFee: true`, and `buyerNativeFeeRequired: false`. The supported
@@ -23,15 +23,18 @@ warpmetal renewal configure \
23
23
  --allowed-asset <exact-contract-or-mint> \
24
24
  --wallet <agent-wallet-name> \
25
25
  [--refill-target-atomic <target>] \
26
- [--email <human-address>] \
26
+ [--email <human-address> | --without-email-notifications] \
27
27
  --json
28
28
  ```
29
29
 
30
- `--email` queues verification; refill and expanded lifecycle notices remain
31
- disabled until the human opens the verification link. A refill target is a
32
- funding target, not spend permission, and cannot exceed the per-renewal cap.
33
- The human may transfer more than the displayed deficit. The agent may spend
34
- only the exact renewal amount allowed by policy.
30
+ Without an active verified notification email, configuration asks for one
31
+ before changing policy. `--email` queues verification; refill and expanded
32
+ lifecycle notices remain disabled until the human opens the verification link.
33
+ Use `--without-email-notifications` only as an explicit opt-out; no signed
34
+ refill-email workflow is returned in that state. A refill target is a funding
35
+ target, not spend permission, and cannot exceed the per-renewal cap. The human
36
+ may transfer more than the displayed deficit. The agent may spend only the
37
+ exact renewal amount allowed by policy.
35
38
 
36
39
  The WarpMetal CLI stores only the local wallet name. The backend stores
37
40
  non-secret limits, counters, payment rail, time horizon, and an opaque
@@ -70,8 +73,10 @@ one policy-compatible rail, and returns:
70
73
  ```text
71
74
  paymentWorkflow.authorize.argv
72
75
  paymentWorkflow.submit.argv
73
- refillWorkflow.environment
74
- refillWorkflow.argv
76
+ paymentWorkflow.fundingWorkflow
77
+ refillNotification
78
+ refillWorkflow.environment (verified email only)
79
+ refillWorkflow.argv (verified email only)
75
80
  ```
76
81
 
77
82
  Invoke the authorize argv once. It writes an owner-only artifact. Then invoke
@@ -112,10 +117,12 @@ authoritative balance before queuing email. The intent cannot name a recipient
112
117
  email, tenant, or product.
113
118
 
114
119
  In a human conversation, also display the exact deficit, network, token symbol,
115
- contract or mint, and payer wallet address. Tell the human to fund the payer
120
+ contract or mint, and payer wallet address. Render only that public address as
121
+ a QR code and repeat it as copyable text. Tell the human to fund the payer
116
122
  wallet address—not the token contract/mint and not WarpMetal's merchant
117
123
  recipient. Do not ask for ETH or SOL because supported network fees are
118
- sponsored.
124
+ sponsored. A refill email repeats the address as text and includes a locally
125
+ generated QR encoding only that address.
119
126
 
120
127
  ## Stop and recovery states
121
128
 
package/src/api.js CHANGED
@@ -128,19 +128,19 @@ export class WarpMetalClient {
128
128
  }
129
129
 
130
130
  health() {
131
- return this.request("GET", "/api/health");
131
+ return this.request("GET", "/health");
132
132
  }
133
133
 
134
134
  catalog() {
135
- return this.request("GET", "/api/catalog");
135
+ return this.request("GET", "/catalog");
136
136
  }
137
137
 
138
138
  prepareOrder(body, idempotencyKey) {
139
- return this.request("POST", "/api/orders", { body, idempotencyKey });
139
+ return this.request("POST", "/orders", { body, idempotencyKey });
140
140
  }
141
141
 
142
142
  getTask(taskId, token) {
143
- return this.request("GET", `/api/tasks/${encodeURIComponent(taskId)}`, {
143
+ return this.request("GET", `/tasks/${encodeURIComponent(taskId)}`, {
144
144
  token,
145
145
  });
146
146
  }
@@ -157,7 +157,7 @@ export class WarpMetalClient {
157
157
  issueSshChallenge(serverId) {
158
158
  return this.request(
159
159
  "POST",
160
- `/api/servers/${encodeURIComponent(serverId)}/auth/challenges`,
160
+ `/servers/${encodeURIComponent(serverId)}/auth/challenges`,
161
161
  { body: {} },
162
162
  );
163
163
  }
@@ -165,13 +165,13 @@ export class WarpMetalClient {
165
165
  exchangeSshChallenge(serverId, challengeId, signature) {
166
166
  return this.request(
167
167
  "POST",
168
- `/api/servers/${encodeURIComponent(serverId)}/auth/tokens`,
168
+ `/servers/${encodeURIComponent(serverId)}/auth/tokens`,
169
169
  { body: { challengeId, signature } },
170
170
  );
171
171
  }
172
172
 
173
173
  getServer(serverId, token) {
174
- return this.request("GET", `/api/servers/${encodeURIComponent(serverId)}`, {
174
+ return this.request("GET", `/servers/${encodeURIComponent(serverId)}`, {
175
175
  token,
176
176
  });
177
177
  }
@@ -179,7 +179,7 @@ export class WarpMetalClient {
179
179
  getRenewalPolicy(serverId, token) {
180
180
  return this.request(
181
181
  "GET",
182
- `/api/servers/${encodeURIComponent(serverId)}/renewal-policy`,
182
+ `/servers/${encodeURIComponent(serverId)}/renewal-policy`,
183
183
  { token },
184
184
  );
185
185
  }
@@ -187,7 +187,7 @@ export class WarpMetalClient {
187
187
  putRenewalPolicy(serverId, body, token) {
188
188
  return this.request(
189
189
  "PUT",
190
- `/api/servers/${encodeURIComponent(serverId)}/renewal-policy`,
190
+ `/servers/${encodeURIComponent(serverId)}/renewal-policy`,
191
191
  { body, token },
192
192
  );
193
193
  }
@@ -195,7 +195,7 @@ export class WarpMetalClient {
195
195
  deleteRenewalPolicy(serverId, token) {
196
196
  return this.request(
197
197
  "DELETE",
198
- `/api/servers/${encodeURIComponent(serverId)}/renewal-policy`,
198
+ `/servers/${encodeURIComponent(serverId)}/renewal-policy`,
199
199
  { token },
200
200
  );
201
201
  }
@@ -203,7 +203,7 @@ export class WarpMetalClient {
203
203
  getNotifications(serverId, token) {
204
204
  return this.request(
205
205
  "GET",
206
- `/api/servers/${encodeURIComponent(serverId)}/notifications`,
206
+ `/servers/${encodeURIComponent(serverId)}/notifications`,
207
207
  { token },
208
208
  );
209
209
  }
@@ -211,13 +211,21 @@ export class WarpMetalClient {
211
211
  putNotifications(serverId, body, token) {
212
212
  return this.request(
213
213
  "PUT",
214
- `/api/servers/${encodeURIComponent(serverId)}/notifications`,
214
+ `/servers/${encodeURIComponent(serverId)}/notifications`,
215
215
  { body, token },
216
216
  );
217
217
  }
218
218
 
219
+ deleteNotifications(serverId, token) {
220
+ return this.request(
221
+ "DELETE",
222
+ `/servers/${encodeURIComponent(serverId)}/notifications`,
223
+ { token },
224
+ );
225
+ }
226
+
219
227
  renewalCheckout(planId, { bodyText, token, paymentSignature }) {
220
- return this.checkout(`/api/checkout/${encodeURIComponent(planId)}/renew`, {
228
+ return this.checkout(`/checkout/${encodeURIComponent(planId)}/renew`, {
221
229
  bodyText,
222
230
  token,
223
231
  paymentSignature,
@@ -227,7 +235,7 @@ export class WarpMetalClient {
227
235
  powerServer(serverId, action, token, idempotencyKey) {
228
236
  return this.request(
229
237
  "POST",
230
- `/api/servers/${encodeURIComponent(serverId)}/power`,
238
+ `/servers/${encodeURIComponent(serverId)}/power`,
231
239
  {
232
240
  body: { action },
233
241
  token,
@@ -239,7 +247,7 @@ export class WarpMetalClient {
239
247
  reloadServer(serverId, body, token, idempotencyKey) {
240
248
  return this.request(
241
249
  "POST",
242
- `/api/servers/${encodeURIComponent(serverId)}/reload`,
250
+ `/servers/${encodeURIComponent(serverId)}/reload`,
243
251
  {
244
252
  body,
245
253
  token,
@@ -251,7 +259,7 @@ export class WarpMetalClient {
251
259
  getOperation(operationId, token) {
252
260
  return this.request(
253
261
  "GET",
254
- `/api/operations/${encodeURIComponent(operationId)}`,
262
+ `/operations/${encodeURIComponent(operationId)}`,
255
263
  {
256
264
  token,
257
265
  },
@@ -261,7 +269,7 @@ export class WarpMetalClient {
261
269
  enableRuntime(serverId, token, idempotencyKey) {
262
270
  return this.request(
263
271
  "POST",
264
- `/api/servers/${encodeURIComponent(serverId)}/runtime/enable`,
272
+ `/servers/${encodeURIComponent(serverId)}/runtime/enable`,
265
273
  {
266
274
  body: {},
267
275
  token,
@@ -273,7 +281,7 @@ export class WarpMetalClient {
273
281
  getRuntime(serverId, token) {
274
282
  return this.request(
275
283
  "GET",
276
- `/api/servers/${encodeURIComponent(serverId)}/runtime`,
284
+ `/servers/${encodeURIComponent(serverId)}/runtime`,
277
285
  { token },
278
286
  );
279
287
  }
@@ -281,7 +289,7 @@ export class WarpMetalClient {
281
289
  bootstrapRuntime(serverId, token, idempotencyKey) {
282
290
  return this.request(
283
291
  "POST",
284
- `/api/servers/${encodeURIComponent(serverId)}/runtime/bootstrap`,
292
+ `/servers/${encodeURIComponent(serverId)}/runtime/bootstrap`,
285
293
  { body: {}, token, idempotencyKey },
286
294
  );
287
295
  }
@@ -289,7 +297,7 @@ export class WarpMetalClient {
289
297
  listSandboxes(serverId, token) {
290
298
  return this.request(
291
299
  "GET",
292
- `/api/servers/${encodeURIComponent(serverId)}/sandboxes`,
300
+ `/servers/${encodeURIComponent(serverId)}/sandboxes`,
293
301
  { token },
294
302
  );
295
303
  }
@@ -297,7 +305,7 @@ export class WarpMetalClient {
297
305
  createSandboxes(serverId, sandboxes, token, idempotencyKey) {
298
306
  return this.request(
299
307
  "POST",
300
- `/api/servers/${encodeURIComponent(serverId)}/sandboxes`,
308
+ `/servers/${encodeURIComponent(serverId)}/sandboxes`,
301
309
  {
302
310
  body: { sandboxes },
303
311
  token,
@@ -309,7 +317,7 @@ export class WarpMetalClient {
309
317
  getSandbox(serverId, sandboxId, token) {
310
318
  return this.request(
311
319
  "GET",
312
- `/api/servers/${encodeURIComponent(serverId)}/sandboxes/${encodeURIComponent(sandboxId)}`,
320
+ `/servers/${encodeURIComponent(serverId)}/sandboxes/${encodeURIComponent(sandboxId)}`,
313
321
  { token },
314
322
  );
315
323
  }
@@ -317,7 +325,7 @@ export class WarpMetalClient {
317
325
  sandboxAction(serverId, sandboxId, action, token, idempotencyKey) {
318
326
  return this.request(
319
327
  "POST",
320
- `/api/servers/${encodeURIComponent(serverId)}/sandboxes/${encodeURIComponent(sandboxId)}/actions`,
328
+ `/servers/${encodeURIComponent(serverId)}/sandboxes/${encodeURIComponent(sandboxId)}/actions`,
321
329
  { body: { action }, token, idempotencyKey },
322
330
  );
323
331
  }
@@ -325,7 +333,7 @@ export class WarpMetalClient {
325
333
  deleteSandbox(serverId, sandboxId, token, idempotencyKey) {
326
334
  return this.request(
327
335
  "POST",
328
- `/api/servers/${encodeURIComponent(serverId)}/sandboxes/${encodeURIComponent(sandboxId)}/delete`,
336
+ `/servers/${encodeURIComponent(serverId)}/sandboxes/${encodeURIComponent(sandboxId)}/delete`,
329
337
  { body: { confirm: "DELETE" }, token, idempotencyKey },
330
338
  );
331
339
  }
@@ -333,7 +341,7 @@ export class WarpMetalClient {
333
341
  listAccessGrants(serverId, sandboxId, token) {
334
342
  return this.request(
335
343
  "GET",
336
- `/api/servers/${encodeURIComponent(serverId)}/sandboxes/${encodeURIComponent(sandboxId)}/access-grants`,
344
+ `/servers/${encodeURIComponent(serverId)}/sandboxes/${encodeURIComponent(sandboxId)}/access-grants`,
337
345
  { token },
338
346
  );
339
347
  }
@@ -341,7 +349,7 @@ export class WarpMetalClient {
341
349
  createAccessGrant(serverId, sandboxId, body, token, idempotencyKey) {
342
350
  return this.request(
343
351
  "POST",
344
- `/api/servers/${encodeURIComponent(serverId)}/sandboxes/${encodeURIComponent(sandboxId)}/access-grants`,
352
+ `/servers/${encodeURIComponent(serverId)}/sandboxes/${encodeURIComponent(sandboxId)}/access-grants`,
345
353
  { body, token, idempotencyKey },
346
354
  );
347
355
  }
@@ -349,7 +357,7 @@ export class WarpMetalClient {
349
357
  getAccessGrant(serverId, sandboxId, grantId, token) {
350
358
  return this.request(
351
359
  "GET",
352
- `/api/servers/${encodeURIComponent(serverId)}/sandboxes/${encodeURIComponent(sandboxId)}/access-grants/${encodeURIComponent(grantId)}`,
360
+ `/servers/${encodeURIComponent(serverId)}/sandboxes/${encodeURIComponent(sandboxId)}/access-grants/${encodeURIComponent(grantId)}`,
353
361
  { token },
354
362
  );
355
363
  }
@@ -357,7 +365,7 @@ export class WarpMetalClient {
357
365
  revokeAccessGrant(serverId, sandboxId, grantId, token, idempotencyKey) {
358
366
  return this.request(
359
367
  "POST",
360
- `/api/servers/${encodeURIComponent(serverId)}/sandboxes/${encodeURIComponent(sandboxId)}/access-grants/${encodeURIComponent(grantId)}/revoke`,
368
+ `/servers/${encodeURIComponent(serverId)}/sandboxes/${encodeURIComponent(sandboxId)}/access-grants/${encodeURIComponent(grantId)}/revoke`,
361
369
  { body: { confirm: "REVOKE" }, token, idempotencyKey },
362
370
  );
363
371
  }
package/src/cli.js CHANGED
@@ -84,6 +84,7 @@ Usage:
84
84
  warpmetal renewal configure --server <serverId> --renew-before-days <n>
85
85
  --maximum-payment-atomic <amount> (--maximum-renewals <n> | --renew-through <UTC>)
86
86
  --allowed-network <CAIP-2> --allowed-asset <asset> --wallet <name>
87
+ [--email <address> | --without-email-notifications]
87
88
  warpmetal renewal status|prepare|submit|run ...
88
89
  warpmetal renewal due (--server <serverId> | --all)
89
90
  warpmetal notifications configure --server <serverId> --email <address>
@@ -364,9 +365,15 @@ async function pollGrant(
364
365
 
365
366
  function challengeResult(taskId, checkoutBody, response) {
366
367
  const paymentRequired = response.headers["payment-required"];
368
+ const challengeHandle = response.headers["x-x402api-challenge-handle"];
367
369
  if (response.status === 402 && !paymentRequired) {
368
370
  throw new CliError("WarpMetal returned HTTP 402 without PAYMENT-REQUIRED.");
369
371
  }
372
+ if (response.status === 402 && !challengeHandle) {
373
+ throw new CliError(
374
+ "WarpMetal returned HTTP 402 without X-X402API-Challenge-Handle.",
375
+ );
376
+ }
370
377
  return {
371
378
  status: response.data?.status,
372
379
  taskId,
@@ -374,6 +381,7 @@ function challengeResult(taskId, checkoutBody, response) {
374
381
  response.data?.paymentAttemptId ||
375
382
  response.headers["x-warpmetal-payment-attempt"],
376
383
  paymentRequired,
384
+ challengeHandle,
377
385
  checkoutBodySha256: createHash("sha256").update(checkoutBody).digest("hex"),
378
386
  };
379
387
  }
@@ -645,7 +653,7 @@ async function handleCheckoutChallenge(client, store, options, context) {
645
653
  stringOption(options, "request-envelope-out"),
646
654
  );
647
655
  const paymentInstructions = safe.paymentWorkflow
648
- ? `\nWallet package: ${safe.paymentWorkflow.signerPackage.spec} (Node ${safe.paymentWorkflow.signerNodeRequirement})\nInstall: ${shellCommand(safe.paymentWorkflow.signerPackage.install.argv)}\nVerify: ${shellCommand(safe.paymentWorkflow.signerContract.probe.argv)}\nRequest envelope: ${safe.paymentWorkflow.requestEnvelopePath}\nAuthorize: ${shellCommand(safe.paymentWorkflow.authorize.argv)}\nSubmit with WarpMetal: ${shellCommand(safe.paymentWorkflow.submit.argv)}`
656
+ ? `\nWallet package: ${safe.paymentWorkflow.signerPackage.spec} (Node ${safe.paymentWorkflow.signerNodeRequirement})\nInstall: ${shellCommand(safe.paymentWorkflow.signerPackage.install.argv)}\nVerify: ${shellCommand(safe.paymentWorkflow.signerContract.probe.argv)}\nRequest envelope: ${safe.paymentWorkflow.requestEnvelopePath}\nAuthorize: ${shellCommand(safe.paymentWorkflow.authorize.argv)}\nIf funding is short, run ${shellCommand(safe.paymentWorkflow.fundingWorkflow.address.argv)} and ${shellCommand(safe.paymentWorkflow.fundingWorkflow.balance.argv)}, then show the payer address as both a QR code and copyable text.\nSubmit with WarpMetal: ${shellCommand(safe.paymentWorkflow.submit.argv)}`
649
657
  : "";
650
658
  emit(
651
659
  context.stdout,
@@ -775,6 +783,44 @@ export function refillRenewBy(termEndsAt, currentTime = Date.now()) {
775
783
  return new Date(Number.isFinite(parsed) && parsed > minimum ? parsed : minimum).toISOString();
776
784
  }
777
785
 
786
+ function activeNotificationSubscription(notifications) {
787
+ const subscription = notifications?.subscription;
788
+ return Boolean(
789
+ notifications?.configured &&
790
+ subscription?.verified &&
791
+ !subscription?.disabled,
792
+ );
793
+ }
794
+
795
+ function refillNotificationState(notifications, policy) {
796
+ const subscription = notifications?.subscription;
797
+ if (!notifications?.configured || !subscription || subscription.disabled) {
798
+ return {
799
+ available: false,
800
+ reason: "verified_email_required",
801
+ subscription: subscription || null,
802
+ };
803
+ }
804
+ if (!subscription.verified) {
805
+ return {
806
+ available: false,
807
+ reason: "email_verification_required",
808
+ subscription,
809
+ };
810
+ }
811
+ if (
812
+ policy?.notificationReference &&
813
+ subscription.reference !== policy.notificationReference
814
+ ) {
815
+ return {
816
+ available: false,
817
+ reason: "notification_reference_mismatch",
818
+ subscription,
819
+ };
820
+ }
821
+ return { available: true, reason: null, subscription };
822
+ }
823
+
778
824
  async function handleRenewalConfigure(client, store, options, context) {
779
825
  const serverId = stringOption(options, "server", { required: true });
780
826
  const wallet = stringOption(options, "wallet", { required: true });
@@ -818,7 +864,72 @@ async function handleRenewalConfigure(client, store, options, context) {
818
864
  for (const [key, value] of Object.entries(body)) {
819
865
  if (value === undefined) delete body[key];
820
866
  }
867
+ const email = stringOption(options, "email");
868
+ const withoutEmailNotifications = booleanOption(
869
+ options,
870
+ "without-email-notifications",
871
+ );
872
+ if (email && withoutEmailNotifications) {
873
+ throw new CliError(
874
+ "Use either --email or --without-email-notifications, not both.",
875
+ { exitCode: 2 },
876
+ );
877
+ }
821
878
  const token = await requireServerToken(store, serverId, options, context.env);
879
+ let notifications = (await client.getNotifications(serverId, token)).data;
880
+ if (
881
+ body.enabled &&
882
+ !email &&
883
+ !withoutEmailNotifications &&
884
+ !activeNotificationSubscription(notifications)
885
+ ) {
886
+ const verificationPending = Boolean(
887
+ notifications.configured &&
888
+ notifications.subscription &&
889
+ !notifications.subscription.disabled,
890
+ );
891
+ const action = verificationPending
892
+ ? "email_verification_required"
893
+ : "email_required";
894
+ const output = {
895
+ action,
896
+ serverId,
897
+ reason: verificationPending
898
+ ? "Verify the existing notification email or resend verification with --email."
899
+ : "A verified email enables renewal and wallet-refill notifications.",
900
+ notifications,
901
+ next: {
902
+ configureEmail: `warpmetal renewal configure ... --email <address>`,
903
+ continueWithoutEmail:
904
+ "warpmetal renewal configure ... --without-email-notifications",
905
+ },
906
+ };
907
+ emit(
908
+ context.stdout,
909
+ output,
910
+ context.json,
911
+ verificationPending
912
+ ? `Verify the notification email for ${serverId}, or rerun with --email to resend verification. To opt out explicitly, rerun with --without-email-notifications.`
913
+ : `An email is needed for renewal and wallet-refill notifications. Rerun with --email <address>, or opt out explicitly with --without-email-notifications.`,
914
+ );
915
+ return 6;
916
+ }
917
+ if (email) {
918
+ notifications = (
919
+ await client.putNotifications(serverId, { email }, token)
920
+ ).data;
921
+ } else if (
922
+ withoutEmailNotifications &&
923
+ notifications.configured &&
924
+ notifications.subscription &&
925
+ !notifications.subscription.disabled
926
+ ) {
927
+ await client.deleteNotifications(serverId, token);
928
+ notifications = {
929
+ ...notifications,
930
+ subscription: { ...notifications.subscription, disabled: true },
931
+ };
932
+ }
822
933
  const result = await client.putRenewalPolicy(serverId, body, token);
823
934
  await store.saveRenewalPolicy(
824
935
  serverId,
@@ -826,19 +937,27 @@ async function handleRenewalConfigure(client, store, options, context) {
826
937
  wallet,
827
938
  refillTargetAtomic,
828
939
  );
829
- const email = stringOption(options, "email");
830
- let notifications;
831
- if (email) {
832
- notifications = (
833
- await client.putNotifications(serverId, { email }, token)
834
- ).data;
835
- }
836
- const output = { ...result.data, wallet, refillTargetAtomic, notifications };
940
+ const notificationState = {
941
+ optedOut: withoutEmailNotifications,
942
+ refillAvailable: activeNotificationSubscription(notifications),
943
+ status: withoutEmailNotifications
944
+ ? "opted_out"
945
+ : activeNotificationSubscription(notifications)
946
+ ? "verified"
947
+ : "verification_required",
948
+ };
949
+ const output = {
950
+ ...result.data,
951
+ wallet,
952
+ refillTargetAtomic,
953
+ notifications,
954
+ notificationState,
955
+ };
837
956
  emit(
838
957
  context.stdout,
839
958
  output,
840
959
  context.json,
841
- `Configured bounded renewal for ${serverId} with wallet ${wallet}.${email ? " Check the email verification link." : ""}`,
960
+ `Configured bounded renewal for ${serverId} with wallet ${wallet}.${email && !notificationState.refillAvailable ? " Check the email verification link." : withoutEmailNotifications ? " Email and wallet-refill notifications were explicitly skipped." : ""}`,
842
961
  );
843
962
  return 0;
844
963
  }
@@ -866,11 +985,12 @@ async function attachRenewalPaymentWorkflow(
866
985
  serverId,
867
986
  server,
868
987
  renewal,
988
+ notifications,
869
989
  response,
870
990
  requestedEnvelopePath,
871
991
  ) {
872
992
  const bodyText = JSON.stringify({ serverId });
873
- const checkoutPath = `/api/checkout/${server.planId}/renew`;
993
+ const checkoutPath = `/checkout/${server.planId}/renew`;
874
994
  const safe = challengeResult(serverId, bodyText, response);
875
995
  if (!safe.paymentRequired) return safe;
876
996
  const request = createPaymentRequestEnvelope({
@@ -924,6 +1044,7 @@ async function attachRenewalPaymentWorkflow(
924
1044
  taskId: server.id,
925
1045
  serverId,
926
1046
  kind: "renewal",
1047
+ wallet: renewal.wallet,
927
1048
  requestEnvelopePath,
928
1049
  paymentArtifactPath: defaults.paymentArtifactPath,
929
1050
  });
@@ -932,27 +1053,39 @@ async function attachRenewalPaymentWorkflow(
932
1053
  BigInt(renewal.refillTargetAtomic) >= BigInt(term.amountAtomic)
933
1054
  ? renewal.refillTargetAtomic
934
1055
  : term.amountAtomic;
935
- const refillWorkflow = {
936
- environment: {
937
- X402API_NOTIFICATION_URL: `${client.baseUrl}/api/notifications/x402api/refill`,
938
- },
939
- argv: [
940
- "x402api",
941
- "wallet",
942
- "notify-refill",
943
- "--wallet",
944
- renewal.wallet,
945
- "--subscription-reference",
946
- renewal.policy.notificationReference,
947
- "--renew-by",
948
- refillRenewBy(server.termEndsAt),
949
- "--target-balance-atomic",
950
- refillTarget,
951
- "--reason",
952
- "renewal",
953
- "--json",
954
- ],
955
- };
1056
+ Object.assign(workflow.fundingWorkflow, {
1057
+ network: term.network,
1058
+ asset: term.asset,
1059
+ requiredPaymentAtomic: term.amountAtomic,
1060
+ targetBalanceAtomic: refillTarget,
1061
+ });
1062
+ const refillNotification = refillNotificationState(
1063
+ notifications,
1064
+ renewal.policy,
1065
+ );
1066
+ const refillWorkflow = refillNotification.available
1067
+ ? {
1068
+ environment: {
1069
+ X402API_NOTIFICATION_URL: `${client.baseUrl}/notifications/x402api/refill`,
1070
+ },
1071
+ argv: [
1072
+ "x402api",
1073
+ "wallet",
1074
+ "notify-refill",
1075
+ "--wallet",
1076
+ renewal.wallet,
1077
+ "--subscription-reference",
1078
+ refillNotification.subscription.reference,
1079
+ "--renew-by",
1080
+ refillRenewBy(server.termEndsAt),
1081
+ "--target-balance-atomic",
1082
+ refillTarget,
1083
+ "--reason",
1084
+ "renewal",
1085
+ "--json",
1086
+ ],
1087
+ }
1088
+ : undefined;
956
1089
  Object.assign(safe, {
957
1090
  serverId,
958
1091
  paymentTerms: request.terms,
@@ -960,6 +1093,7 @@ async function attachRenewalPaymentWorkflow(
960
1093
  paymentRequestDigest: request.requestDigest,
961
1094
  paymentChallengeDigest: request.challengeDigest,
962
1095
  paymentWorkflow: workflow,
1096
+ refillNotification,
963
1097
  refillWorkflow,
964
1098
  });
965
1099
  await store.saveRenewalChallenge(serverId, {
@@ -968,6 +1102,7 @@ async function attachRenewalPaymentWorkflow(
968
1102
  termEndsAt: server.termEndsAt,
969
1103
  paymentRequired: safe.paymentRequired,
970
1104
  paymentAttemptId: safe.paymentAttemptId,
1105
+ challengeHandle: safe.challengeHandle,
971
1106
  paymentRequestDigest: safe.paymentRequestDigest,
972
1107
  paymentChallengeDigest: safe.paymentChallengeDigest,
973
1108
  paymentRequestEnvelopePath: requestEnvelopePath,
@@ -988,9 +1123,10 @@ function renewalActionExitCode(action) {
988
1123
 
989
1124
  async function prepareRenewal(client, store, serverId, options, context) {
990
1125
  const token = await requireServerToken(store, serverId, options, context.env);
991
- const [serverResult, policyResult] = await Promise.all([
1126
+ const [serverResult, policyResult, notificationsResult] = await Promise.all([
992
1127
  client.getServer(serverId, token),
993
1128
  client.getRenewalPolicy(serverId, token),
1129
+ client.getNotifications(serverId, token),
994
1130
  ]);
995
1131
  if (!policyResult.data.configured) {
996
1132
  return {
@@ -1032,6 +1168,7 @@ async function prepareRenewal(client, store, serverId, options, context) {
1032
1168
  serverId,
1033
1169
  serverResult.data.task,
1034
1170
  local,
1171
+ notificationsResult.data,
1035
1172
  response,
1036
1173
  stringOption(options, "request-envelope-out"),
1037
1174
  );
@@ -1053,8 +1190,16 @@ async function prepareRenewal(client, store, serverId, options, context) {
1053
1190
  async function handleRenewalPrepare(client, store, options, context) {
1054
1191
  const serverId = stringOption(options, "server", { required: true });
1055
1192
  const safe = await prepareRenewal(client, store, serverId, options, context);
1193
+ const fundingInstructions = safe.paymentWorkflow
1194
+ ? `\nIf funding is short, run ${shellCommand(safe.paymentWorkflow.fundingWorkflow.address.argv)} and ${shellCommand(safe.paymentWorkflow.fundingWorkflow.balance.argv)}. Show the returned payer address as both a QR code and copyable text, with the exact network, asset, and deficit.`
1195
+ : "";
1196
+ const refillInstructions = safe.refillWorkflow
1197
+ ? `\nFor verified email refill, set ${Object.entries(safe.refillWorkflow.environment).map(([key, value]) => `${key}=${value}`).join(" ")} and run: ${shellCommand(safe.refillWorkflow.argv)}`
1198
+ : safe.paymentWorkflow
1199
+ ? `\nVerified email refill is unavailable (${safe.refillNotification.reason}). Configure and verify notifications, or fund the displayed payer address directly.`
1200
+ : "";
1056
1201
  const instructions = safe.paymentWorkflow
1057
- ? `\nAuthorize: ${shellCommand(safe.paymentWorkflow.authorize.argv)}\nIf the wallet is short of funds, set ${Object.entries(safe.refillWorkflow.environment).map(([key, value]) => `${key}=${value}`).join(" ")} and run: ${shellCommand(safe.refillWorkflow.argv)}\nSubmit: ${shellCommand(safe.paymentWorkflow.submit.argv)}`
1202
+ ? `\nAuthorize: ${shellCommand(safe.paymentWorkflow.authorize.argv)}${fundingInstructions}${refillInstructions}\nSubmit: ${shellCommand(safe.paymentWorkflow.submit.argv)}`
1058
1203
  : "";
1059
1204
  emit(
1060
1205
  context.stdout,
@@ -2325,6 +2470,7 @@ async function dispatch(positionals, options, passthrough, context) {
2325
2470
  "allowed-asset",
2326
2471
  "refill-target-atomic",
2327
2472
  "email",
2473
+ "without-email-notifications",
2328
2474
  "disabled",
2329
2475
  ]);
2330
2476
  return handleRenewalConfigure(client, store, options, context);
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.1";
18
+ export const AGENT_WALLET_VERSION = "0.2.2";
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";
@@ -696,6 +696,7 @@ export function paymentWorkflow({
696
696
  taskId,
697
697
  serverId,
698
698
  kind = "checkout",
699
+ wallet = "<wallet-name>",
699
700
  requestEnvelopePath,
700
701
  paymentArtifactPath,
701
702
  }) {
@@ -739,7 +740,7 @@ export function paymentWorkflow({
739
740
  "payment",
740
741
  "authorize",
741
742
  "--wallet",
742
- "<wallet-name>",
743
+ wallet,
743
744
  "--request-envelope",
744
745
  requestEnvelopePath,
745
746
  "--artifact-out",
@@ -747,6 +748,27 @@ export function paymentWorkflow({
747
748
  "--json",
748
749
  ],
749
750
  },
751
+ fundingWorkflow: {
752
+ action: "fund_wallet",
753
+ trigger: "insufficient_asset_balance",
754
+ wallet,
755
+ address: {
756
+ argv: ["x402api", "wallet", "address", "--wallet", wallet, "--json"],
757
+ },
758
+ balance: {
759
+ argv: ["x402api", "wallet", "balance", "--wallet", wallet, "--json"],
760
+ },
761
+ presentation: {
762
+ qrPayloadField: "address",
763
+ textPayloadField: "address",
764
+ showQr: true,
765
+ showAddressString: true,
766
+ showExactDeficit: true,
767
+ showNetworkAndAsset: true,
768
+ },
769
+ safety:
770
+ "Encode and fund only the payer wallet public address. Never fund the token contract, mint, or WarpMetal merchant recipient; sponsored payments do not require ETH or SOL.",
771
+ },
750
772
  submit: {
751
773
  argv:
752
774
  kind === "renewal"
package/src/state.js CHANGED
@@ -280,6 +280,7 @@ export class StateStore {
280
280
  {
281
281
  paymentRequired,
282
282
  paymentAttemptId,
283
+ challengeHandle,
283
284
  paymentRequestDigest,
284
285
  paymentChallengeDigest,
285
286
  paymentWorkflow,
@@ -293,6 +294,7 @@ export class StateStore {
293
294
  });
294
295
  order.paymentRequired = paymentRequired;
295
296
  order.paymentAttemptId = paymentAttemptId;
297
+ order.challengeHandle = challengeHandle;
296
298
  order.paymentRequestDigest = paymentRequestDigest;
297
299
  order.paymentChallengeDigest = paymentChallengeDigest;
298
300
  order.paymentRequestEnvelopePath = paymentWorkflow?.requestEnvelopePath;
@@ -465,6 +467,7 @@ export class StateStore {
465
467
  serverId: order.serverId,
466
468
  planId: order.planId,
467
469
  paymentAttemptId: order.paymentAttemptId,
470
+ challengeHandle: order.challengeHandle,
468
471
  walletPaymentAttemptId: order.walletPaymentAttemptId,
469
472
  walletName: order.walletName,
470
473
  payerAddress: order.walletPayerAddress,
@@ -499,6 +502,7 @@ export class StateStore {
499
502
  refillTargetAtomic: renewal.refillTargetAtomic,
500
503
  policy: renewal.policy,
501
504
  paymentAttemptId: renewal.paymentAttemptId,
505
+ challengeHandle: renewal.challengeHandle,
502
506
  paymentArtifactExpiresAt: renewal.paymentArtifactExpiresAt,
503
507
  })),
504
508
  };