cascrow-cli 1.2.2 → 1.4.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/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## 1.4.0 — 2026-09-25
4
+
5
+ - New `cascrow status --proof <id> [--wait]` — show, or wait for, a proof's verification result. `submit` now points to it: verification starts by itself after an upload.
6
+ - `cascrow verify --proof <id>` on a proof whose verification is already running waits for that run instead of failing.
7
+ - `cascrow create` takes `--criteria`, `--criteria-file`, `--checklist-file` and `--review-mode` — the bar the AI panel judges against, kept out of the title (which is printed on the certificate).
8
+ - Every API call has a deadline and survives a non-JSON answer; errors end with the API's stable code in brackets, e.g. `[RATE_LIMITED]`.
9
+ - `status` never reports a released milestone as closed.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # cascrow-cli
2
2
 
3
- Command-line interface for [Cascrow](https://cascrow.com) — agentic escrow and verification on the XRP Ledger, for shells, CI pipelines, and subprocesses.
3
+ Command-line interface for [Cascrow](https://cascrow.com) — trust infrastructure for the agent economy (AI verification, on-chain reputation, milestone escrow) on the XRP Ledger, for shells, CI pipelines, and subprocesses.
4
4
 
5
5
  AI agents and their operators use Cascrow to create milestone-based contracts, submit proof of completion, and get verified by a **5-model AI majority vote** — with funds released automatically on-chain. No humans required.
6
6
 
@@ -38,7 +38,10 @@ cascrow verify-work --task "Build a landing page with a working contact form" \
38
38
  ```
39
39
 
40
40
  Returns a decision, confidence score, per-model reasoning, and a public shareable report URL — no
41
- contract, no escrow, no wallet.
41
+ contract, no escrow, no wallet. If `CASCROW_API_KEY` happens to be set in your environment, `verify-work`
42
+ picks it up too (it's optional, not ignored) and the verdict is additionally anchored on the XRP Ledger
43
+ mainnet — a commitment hash you can independently recompute from the report. Without a key, the verdict
44
+ is never anchored.
42
45
 
43
46
  ## All Commands
44
47
 
@@ -50,11 +53,14 @@ contract, no escrow, no wallet.
50
53
  | `create` | Create a milestone contract | Yes |
51
54
  | `fund` | Activate a milestone (simulation — no on-chain tx) | Yes |
52
55
  | `escrow-fund` | Fund a milestone with real on-chain RLUSD escrow | Yes |
53
- | `submit` | Upload proof text (returns `proofId` for the legacy verify flow) | Yes |
56
+ | `submit` | Upload proof text — verification starts by itself; returns `proofId` | Yes |
57
+ | `status` | Show, or `--wait` for, the verification result of a proof | Yes |
54
58
  | `get` | Get contract status and milestones | Yes |
55
59
  | `join` | Join a contract as Builder via invite code | Yes |
56
60
  | `handoff` | Send a contract invite to a Builder agent by Agent ID | Yes |
57
61
  | `check-invites` | Check pending contract invites for this agent | Yes |
62
+ | `review-queue` | As funder: list milestones still waiting for your verdict | Yes |
63
+ | `review` | As funder: approve or reject a milestone under manual review | Yes |
58
64
  | `me` | Print this agent's ID | Yes |
59
65
 
60
66
  Run `cascrow --help` (or `cascrow`) any time for the full, always-current option reference.
@@ -92,11 +98,18 @@ Exactly one of `--pr`, `--code`, or `--image` is required alongside `--task`.
92
98
  ### `create`
93
99
 
94
100
  ```
95
- --title <title> required milestone title + acceptance criteria
101
+ --title <title> required short milestone title (printed on the certificate)
96
102
  --days <n> optional deadline days from now (default: 7)
97
103
  --amount <usd> optional RLUSD to lock in escrow (default: 0 = verification-only)
104
+ --criteria <text> optional what the AI panel checks the proof against (max 4000 chars)
105
+ --criteria-file <path> optional the same, read from a file
106
+ --checklist-file <path> optional JSON array of { title, severity?, id? } (max 20), scored item by item on text proofs
107
+ --review-mode <mode> optional AUTO | MANUAL | MANUAL_AUTO — what happens when the panel is unsure (60–85%)
98
108
  ```
99
109
 
110
+ Keep acceptance criteria out of `--title`: the title is printed on the public
111
+ certificate, and the panel judges the proof against `--criteria`.
112
+
100
113
  ### `fund`
101
114
 
102
115
  ```
@@ -109,11 +122,11 @@ Exactly one of `--pr`, `--code`, or `--image` is required alongside `--task`.
109
122
  ```
110
123
  --contract <id> required
111
124
  --private-key <key> required EVM private key (funds come from this wallet)
112
- --amount <usd> optional override milestone amount
125
+ --amount <usd> IGNORED the escrowed amount is the milestone's own amountUSD
113
126
  --milestone <id> optional
114
127
  ```
115
128
 
116
- ### `submit` [legacy — use `verify` instead]
129
+ ### `submit` [or use `verify` for text proof in one call]
117
130
 
118
131
  ```
119
132
  --milestone <id> required
@@ -121,6 +134,19 @@ Exactly one of `--pr`, `--code`, or `--image` is required alongside `--task`.
121
134
  --file <name> optional filename (default: proof-report.txt)
122
135
  ```
123
136
 
137
+ Verification starts by itself after the upload — wait for it with `status`, don't
138
+ start it again with `verify --proof` (that would only wait for the same run).
139
+
140
+ ### `status`
141
+
142
+ ```
143
+ --proof <id> required
144
+ --wait optional wait up to ~2 min; stops early on 401/403/404 or an interrupted run
145
+ ```
146
+
147
+ Errors end with the API's stable code in brackets, e.g. `[RATE_LIMITED]` — branch
148
+ on that, not on the wording.
149
+
124
150
  ### `get`
125
151
 
126
152
  ```
@@ -146,6 +172,24 @@ Exactly one of `--pr`, `--code`, or `--image` is required alongside `--task`.
146
172
 
147
173
  No options.
148
174
 
175
+ ### `review-queue`
176
+
177
+ No options. Lists the contracts on which you, as funder, still owe a manual
178
+ verdict — the pull behind the `manual_review.required` webhook. A webhook nobody
179
+ received is a decision nobody makes, and cascrow auto-approves an undecided
180
+ milestone after 48 hours (agent contracts) or 14 days (human) and releases the
181
+ escrow, so poll this whenever you have funded work in flight.
182
+
183
+ ### `review`
184
+
185
+ ```
186
+ --contract <id> required from `cascrow review-queue`
187
+ --decision <APPROVE|REJECT> required APPROVE releases the escrow; REJECT returns
188
+ the milestone to the builder, who can resubmit
189
+ --milestone <id> required once more than one milestone of the contract is
190
+ pending review; `review-queue` names it
191
+ ```
192
+
149
193
  ## Environment
150
194
 
151
195
  | Variable | Description |
package/builder-agent.js CHANGED
@@ -15,24 +15,57 @@ if (!API_KEY) {
15
15
  process.exit(1);
16
16
  }
17
17
 
18
- async function apiPost(path, body) {
19
- const res = await fetch(`${BASE_URL}${path}`, {
20
- method: "POST",
21
- headers: { "Authorization": `Bearer ${API_KEY}`, "Content-Type": "application/json" },
22
- body: JSON.stringify(body),
23
- });
24
- const data = await res.json();
25
- if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
18
+ // Every call reads the body as text and parses it in a try (an HTML 502 used to
19
+ // crash the agent as "Unexpected token '<'"), carries a deadline over the body
20
+ // read, and throws an Error with `status` and `code` (T11-A4). Upload's deadline
21
+ // mirrors UPLOAD_CLIENT_ABORT_MS in src/lib/latency-budget.ts.
22
+ const UPLOAD_CLIENT_ABORT_MS = 120_000;
23
+ const REQUEST_CLIENT_ABORT_MS = 300_000;
24
+
25
+ async function readJsonSafe(res) {
26
+ const text = await res.text();
27
+ if (!text) return {};
28
+ try {
29
+ return JSON.parse(text);
30
+ } catch {
31
+ return { error: `HTTP ${res.status} (non-JSON response from server)`, code: "UPSTREAM_NON_JSON" };
32
+ }
33
+ }
34
+
35
+ async function fetchJson(method, path, { json, form, deadlineMs = REQUEST_CLIENT_ABORT_MS } = {}) {
36
+ const controller = new AbortController();
37
+ const timer = setTimeout(() => controller.abort(), deadlineMs);
38
+ let res;
39
+ let data;
40
+ try {
41
+ res = await fetch(`${BASE_URL}${path}`, {
42
+ method,
43
+ headers: { "Authorization": `Bearer ${API_KEY}`, ...(json !== undefined ? { "Content-Type": "application/json" } : {}) },
44
+ ...(json !== undefined ? { body: JSON.stringify(json) } : form !== undefined ? { body: form } : {}),
45
+ signal: controller.signal,
46
+ });
47
+ data = await readJsonSafe(res);
48
+ } catch (e) {
49
+ if (e && e.name === "AbortError") {
50
+ throw Object.assign(new Error(`No answer from ${path} within ${Math.round(deadlineMs / 1000)} s [CLIENT_TIMEOUT]`), { status: 0, code: "CLIENT_TIMEOUT" });
51
+ }
52
+ throw e;
53
+ } finally {
54
+ clearTimeout(timer);
55
+ }
56
+ if (!res.ok || data.code === "UPSTREAM_NON_JSON") {
57
+ const code = typeof data.code === "string" ? data.code : null;
58
+ throw Object.assign(new Error(`${data.error ?? `HTTP ${res.status}`}${code ? ` [${code}]` : ""}`), { status: res.status, code });
59
+ }
26
60
  return data;
27
61
  }
28
62
 
63
+ async function apiPost(path, body) {
64
+ return fetchJson("POST", path, { json: body });
65
+ }
66
+
29
67
  async function apiGet(path) {
30
- const res = await fetch(`${BASE_URL}${path}`, {
31
- headers: { "Authorization": `Bearer ${API_KEY}` },
32
- });
33
- const data = await res.json();
34
- if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
35
- return data;
68
+ return fetchJson("GET", path);
36
69
  }
37
70
 
38
71
  async function uploadProof(milestoneId, content) {
@@ -40,35 +73,73 @@ async function uploadProof(milestoneId, content) {
40
73
  const form = new FormData();
41
74
  form.append("file", blob, "proof-report.txt");
42
75
  form.append("milestoneId", milestoneId);
43
- const res = await fetch(`${BASE_URL}/api/proof/upload`, {
44
- method: "POST",
45
- headers: { "Authorization": `Bearer ${API_KEY}` },
46
- body: form,
47
- });
48
- const data = await res.json();
49
- if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
50
- return data;
76
+ return fetchJson("POST", "/api/proof/upload", { form, deadlineMs: UPLOAD_CLIENT_ABORT_MS });
51
77
  }
52
78
 
79
+ // SEC-073. The FIFTH client door of `/api/verify`, and until 2026-09-22 the only
80
+ // one with no deadline at all: it was invisible to `latency-budget.test.ts`,
81
+ // whose client list was three hand-typed files — and this file lives inside one
82
+ // of the three packages already on that list, so the subject was PACKAGES where
83
+ // the population is FILES (F-30).
84
+ //
85
+ // 235_000 — one step below the route's 240 s `maxDuration`, NOT "above the
86
+ // worst case": the tail (pre-stream + a 15 s blob read + the 205 s ladder + the
87
+ // bounded EVM retry) reaches ~239 s, so no value is both above it and below the
88
+ // cap. See VERIFY_TAIL_WORST_CASE_MS in src/lib/latency-budget.ts. Cleared after
89
+ // the READ LOOP rather than at the `fetch`: `fetch` resolves on response headers about 12 s in, and the whole
90
+ // retry ladder arrives afterwards out of `res.body`.
53
91
  async function streamVerify(proofId) {
54
- const res = await fetch(`${BASE_URL}/api/verify`, {
55
- method: "POST",
56
- headers: {
57
- "Authorization": `Bearer ${API_KEY}`,
58
- "Content-Type": "application/json",
59
- "Accept": "text/event-stream",
60
- },
61
- body: JSON.stringify({ proofId }),
62
- });
92
+ const controller = new AbortController();
93
+ const timeout = setTimeout(() => controller.abort(), 235_000);
94
+ let res;
95
+ try {
96
+ res = await fetch(`${BASE_URL}/api/verify`, {
97
+ method: "POST",
98
+ headers: {
99
+ "Authorization": `Bearer ${API_KEY}`,
100
+ "Content-Type": "application/json",
101
+ "Accept": "text/event-stream",
102
+ },
103
+ body: JSON.stringify({ proofId }),
104
+ signal: controller.signal,
105
+ });
106
+ } catch (e) {
107
+ clearTimeout(timeout);
108
+ // SEC-073. Now that the deadline is live it can actually fire, and a raw
109
+ // `DOMException: This operation was aborted` names no remedy — worse, per
110
+ // the tail arithmetic in latency-budget.ts the abort can land AFTER
111
+ // releaseMilestone(), so "it failed" may be false about the money.
112
+ if (e && e.name === "AbortError") {
113
+ throw new Error(
114
+ "Verification is still running on the server — this client stopped waiting, it did not fail. " +
115
+ `Poll GET /api/agent/proof-status/${proofId} for the verdict.`
116
+ );
117
+ }
118
+ throw e;
119
+ }
63
120
  if (!res.ok) {
64
- const data = await res.json().catch(() => ({}));
65
- throw new Error(data.error ?? `HTTP ${res.status}`);
121
+ clearTimeout(timeout);
122
+ const data = await readJsonSafe(res);
123
+ // T01-8. Upload starts verification by itself, and this agent then starts it
124
+ // again — so losing the claim to a RUNNING run is the normal case, not a failure.
125
+ // The caller's polling path waits for that run; `alreadyRunning` sends it there.
126
+ // Never read this 409's Retry-After as a wait: it is the ~1 h takeover cutoff.
127
+ if (res.status === 409 && data.code === "VERIFICATION_IN_PROGRESS" && data.phase === "running") {
128
+ throw Object.assign(new Error("A verification of this proof is already running (the one upload started)."), { alreadyRunning: true });
129
+ }
130
+ const code = typeof data.code === "string" ? data.code : null;
131
+ throw Object.assign(new Error(`${data.error ?? `HTTP ${res.status}`}${code ? ` [${code}]` : ""}`), { status: res.status, code });
132
+ }
133
+ if (!res.body) {
134
+ clearTimeout(timeout);
135
+ throw new Error("Verification stream had no body");
66
136
  }
67
137
 
68
138
  const reader = res.body.getReader();
69
139
  const decoder = new TextDecoder();
70
140
  let buffer = "";
71
141
 
142
+ try {
72
143
  while (true) {
73
144
  const { done, value } = await reader.read();
74
145
  if (done) break;
@@ -95,6 +166,21 @@ async function streamVerify(proofId) {
95
166
  }
96
167
  }
97
168
  }
169
+ } catch (e) {
170
+ // The deadline can now fire DURING the read — that is the whole point of
171
+ // clearing it below the loop rather than at the fetch. Same translation as
172
+ // the fetch-side catch above.
173
+ if (e && e.name === "AbortError") {
174
+ throw new Error(
175
+ "Verification is still running on the server — this client stopped waiting, it did not fail. " +
176
+ `Poll GET /api/agent/proof-status/${proofId} for the verdict.`
177
+ );
178
+ }
179
+ throw e;
180
+ } finally {
181
+ clearTimeout(timeout);
182
+ reader.cancel().catch(() => {});
183
+ }
98
184
  throw new Error("Stream ended without complete event");
99
185
  }
100
186
 
@@ -172,28 +258,115 @@ VERIFICATION CRITERIA MET:
172
258
  // Stream disconnected before complete event (e.g. Vercel 150s timeout).
173
259
  // The verification is still running server-side — just poll for the result,
174
260
  // do NOT call streamVerify again (would trigger duplicate AI verification).
175
- console.log(` ⚠️ Stream ended early, polling for result...`);
261
+ // Two ways here, and the loop below serves both: the stream dropped mid-run, or
262
+ // it never opened because upload's own run already held the claim (T01-8).
263
+ if (streamErr && streamErr.alreadyRunning) {
264
+ console.log(` ℹ️ Verification was already running (upload starts it) — waiting for its result...`);
265
+ } else if (streamErr && (streamErr.status === 401 || streamErr.status === 403 || streamErr.status === 404)) {
266
+ throw streamErr;
267
+ } else {
268
+ console.log(` ⚠️ Stream ended early, polling for result...`);
269
+ }
270
+ // SEC-076. This polled `contract.status`, and that column answers a different
271
+ // question than the one asked here. It is a single slot with nine writers:
272
+ // funding a SIBLING milestone writes `contractStatusAfterFunding(...)` over
273
+ // it under `status: { notIn: ["COMPLETED","EXPIRED"] }`, so it fails both
274
+ // ways. False negative: the pin is overwritten with FUNDED, no terminal value
275
+ // ever appears, and this loop dies after five minutes on "Timed out waiting
276
+ // for verification result" for a proof that was in fact decided. False
277
+ // positive: the contract reads VERIFIED/COMPLETED because ANOTHER milestone
278
+ // settled, and the builder is handed a verdict that is not their proof's -
279
+ // `decision` below is derived from it.
280
+ //
281
+ // `GET /api/contracts/{id}` returns `milestones: [{ id, status, … }]`, and we
282
+ // funded a specific one, so the honest read is that row. The contract-level
283
+ // read is kept only for the legacy population that has no milestone rows at
284
+ // all (proofs written via the contractId-only path).
285
+ //
286
+ // EXPIRED is terminal too. Since 2026-09-23 `/api/verify` no longer reopens a
287
+ // milestone that went EXPIRED while its panel ran, so without it this loop polled a
288
+ // closed row for five minutes and then threw "Timed out" — and it must never be
289
+ // mapped to a YES: the milestone moved no funds through this verification.
290
+ const TERMINAL_PROOF_STATES = ["VERIFIED", "COMPLETED", "REJECTED", "PENDING_REVIEW", "EXPIRED"];
176
291
  for (let i = 0; i < 60; i++) {
177
292
  await new Promise((r) => setTimeout(r, 5000));
178
- const contract = await apiGet(`/api/contracts/${joined.contractId}`);
179
- if (["VERIFIED", "COMPLETED", "REJECTED", "PENDING_REVIEW"].includes(contract.status)) {
180
- const decision = ["VERIFIED", "COMPLETED"].includes(contract.status) ? "YES" : "NO";
181
- result = { decision, confidence: 0, action: contract.status };
182
- console.log(` ✓ Got result via polling: ${contract.status}`);
293
+ let contract;
294
+ try {
295
+ contract = await apiGet(`/api/contracts/${joined.contractId}`);
296
+ } catch (e) {
297
+ // 401/403/404 do not change by waiting; anything else is transient. One
298
+ // dropped poll used to end the whole run.
299
+ if (e && (e.status === 401 || e.status === 403 || e.status === 404)) throw e;
300
+ continue;
301
+ }
302
+ const mine = (contract.milestones ?? []).find((m) => m.id === fundedMilestoneId);
303
+ const observed = mine ? mine.status : (contract.milestones ?? []).length === 0 ? contract.status : null;
304
+ if (observed && TERMINAL_PROOF_STATES.includes(observed)) {
305
+ if (observed === "EXPIRED") {
306
+ // No verdict is read on this path: the stream that carried it dropped, and
307
+ // the row says only that it is closed.
308
+ result = { decision: null, confidence: 0, action: "EXPIRED", milestoneClosed: true };
309
+ console.log(` ✓ Got result via polling: ${observed}`);
310
+ break;
311
+ }
312
+ const decision = ["VERIFIED", "COMPLETED"].includes(observed) ? "YES" : "NO";
313
+ result = { decision, confidence: 0, action: observed };
314
+ console.log(` ✓ Got result via polling: ${observed}`);
183
315
  break;
184
316
  }
185
317
  }
186
318
  if (!result) throw new Error("Timed out waiting for verification result");
187
319
  }
188
320
 
189
- const passed = result.decision === "YES" && result.confidence >= 85;
321
+ // SEC-055/058/059. This re-implemented the three-tier rule from the PROOF's verdict
322
+ // (`decision === "YES" && confidence >= 85`), so `COMPLETED`, `SUPERSEDED`, a
323
+ // compliance block and a release hold all printed "✅ VERIFIED", and a panel outage
324
+ // (`decision: "PENDING"`, confidence 0) printed "❌ NOT VERIFIED — 0% confidence" —
325
+ // our own outage read back to the builder as a rejection of their work.
326
+ //
327
+ // `passed` now means FUNDS MOVED, and only `COMPLETED` says that. The polling
328
+ // fallback above already derives `action` from the funded MILESTONE's status
329
+ // (SEC-076; it read the contract row until 2026-09-22), so both paths reach this
330
+ // with the same vocabulary.
331
+ const released = result.action === "COMPLETED";
332
+ const passed = released;
190
333
  console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
191
- if (passed) {
192
- console.log(`✅ VERIFIED — ${result.confidence}% confidence`);
334
+ if (result.milestoneClosed === true && result.action === "EXPIRED") {
335
+ // `GET /api/contracts/{id}` does not return the milestone's `terminalReason`, so this
336
+ // cannot say whether the funder was refunded or the builder was paid on-chain, and
337
+ // does not guess.
338
+ console.log(
339
+ `⛔ CLOSED — the milestone was closed (EXPIRED) while the panel ran or after it decided, so this verification moved no funds. ` +
340
+ `The reason is not available here — check the contract page before assuming where the funds are. Do not resubmit to it.`
341
+ );
342
+ } else if (result.action === "SUPERSEDED") {
343
+ console.log(
344
+ result.settledAs
345
+ ? `ℹ️ SUPERSEDED — this milestone was closed (${result.settledAs}) before this verification could settle it; it changed nothing and moved no funds. Do not resubmit to it.`
346
+ : `ℹ️ SUPERSEDED — another proof on this milestone was decided first; this changed nothing.`
347
+ );
348
+ } else if (released) {
349
+ console.log(
350
+ result.alreadyReleased === true
351
+ ? `✅ COMPLETED — already released by another request. The funds are out; do not resubmit.`
352
+ : `✅ COMPLETED — ${result.confidence}% confidence, funds released.`
353
+ );
354
+ } else if (result.complianceBlocked === true) {
355
+ console.log(`⏸️ VERIFIED — payout on hold pending compliance review. No funds moved.`);
356
+ } else if (result.releaseHoldUntil) {
357
+ console.log(`⏸️ VERIFIED — approved, payout scheduled for ${result.releaseHoldUntil}. NO FUNDS HAVE MOVED YET.`);
358
+ } else if (result.action === "VERIFIED") {
359
+ console.log(`⚠️ VERIFIED — approved, but the automatic payout did NOT go through. No funds moved.`);
360
+ } else if (result.timedOut === true) {
361
+ console.log(`⏳ PENDING_REVIEW — the panel did not answer (too few models). NOT a rejection; a human decides.`);
362
+ } else if (result.action === "PENDING_REVIEW") {
363
+ console.log(`⏳ PENDING_REVIEW — ${result.confidence}% confidence, manual review required.`);
364
+ } else if (result.action === "REJECTED") {
365
+ console.log(`❌ REJECTED — ${result.confidence}% confidence.`);
193
366
  } else {
194
- console.log(`❌ NOT VERIFIED — ${result.confidence}% confidence`);
367
+ console.log(`⚠️ Outcome not recognised by this version (action: ${result.action}). No conclusion drawn.`);
195
368
  }
196
- console.log(` Decision: ${result.decision}`);
369
+ console.log(` Decision: ${result.decision ?? "none read (the stream dropped before it)"}`);
197
370
  console.log(` View contract: ${BASE_URL}/contract/${invite.contractId}`);
198
371
  console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
199
372
  }
@@ -210,13 +383,30 @@ async function main() {
210
383
  console.log(` Share this ID with the Requester agent`);
211
384
  console.log(` Polling for work every ${POLL_INTERVAL_MS / 1000}s...\n`);
212
385
 
213
- // Register EVM wallet address so Requester can fund real escrow to this agent
386
+ // Register EVM wallet address so Requester can fund real escrow to this agent.
387
+ //
388
+ // POST /api/user/wallet proves key possession: it wants a challenge token from
389
+ // GET /api/user/wallet/challenge plus a signature over the nonce it carries.
390
+ // This CLI is deliberately dependency-free and Node's crypto cannot produce an
391
+ // EIP-191 signature (no keccak256, no recovery id), so it cannot complete that
392
+ // flow on its own — and posting the address alone has been answered with 400
393
+ // since the check went in. Say so plainly instead of warning past it: a
394
+ // builder whose wallet is not linked is not discoverable and cannot be paid,
395
+ // which is worth more than one line of yellow text.
214
396
  if (AGENT_EVM_WALLET) {
215
397
  try {
216
398
  await apiPost("/api/user/wallet", { walletAddress: AGENT_EVM_WALLET });
217
399
  console.log(` 💳 EVM wallet registered: ${AGENT_EVM_WALLET}\n`);
218
400
  } catch (e) {
219
- console.warn(` ⚠️ Could not register wallet: ${e.message}`);
401
+ console.warn(
402
+ ` ⚠️ Wallet NOT linked (${e.message}).\n` +
403
+ ` ${AGENT_EVM_WALLET} is unusable until it is: escrow cannot be funded to it\n` +
404
+ ` and this agent will not appear in /api/agent/discover.\n` +
405
+ ` Link it once, from a signer that holds the key:\n` +
406
+ ` 1. GET ${BASE_URL}/api/user/wallet/challenge (Bearer <your API key>)\n` +
407
+ ` 2. sign the nonce in the returned token with the wallet's key\n` +
408
+ ` 3. POST ${BASE_URL}/api/user/wallet { walletAddress, signature, token }\n`
409
+ );
220
410
  }
221
411
  }
222
412