cascrow-sdk 0.2.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/CHANGELOG.md +32 -0
- package/README.md +44 -14
- package/dist/client.d.ts +349 -43
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +687 -65
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +226 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/v3.d.ts +128 -0
- package/dist/v3.d.ts.map +1 -0
- package/dist/v3.js +105 -0
- package/dist/v3.js.map +1 -0
- package/package.json +8 -4
package/dist/client.js
CHANGED
|
@@ -1,17 +1,231 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { TX_HASH_RE, V3_CLIENT_TEXT, v3HintFor, v3RefusalDetail } from "./v3.js";
|
|
2
|
+
/**
|
|
3
|
+
* Client deadlines, mirrored from `src/lib/latency-budget.ts` (SEC-073).
|
|
4
|
+
*
|
|
5
|
+
* A published package cannot import from the app, so these are copies by
|
|
6
|
+
* necessity. `standalone-client-deadline.test.ts` is what keeps every copy in the
|
|
7
|
+
* tree equal — a number typed in FOUR places (this file, `cascrow-mcp`,
|
|
8
|
+
* `cascrow-cli`, and the owner in `src/lib/latency-budget.ts`) is four numbers
|
|
9
|
+
* that agree today.
|
|
10
|
+
*/
|
|
11
|
+
/** `/api/verify` streams under a 240 s function cap; one step below its kill. */
|
|
12
|
+
const CLIENT_ABORT_MS = 235_000;
|
|
13
|
+
/** `/api/verify/standalone` is capped at 120 s. */
|
|
14
|
+
const STANDALONE_CLIENT_ABORT_MS = 115_000;
|
|
15
|
+
/**
|
|
16
|
+
* The default for every other call. The longest `maxDuration` any route in this
|
|
17
|
+
* API declares is 240 s, so this cannot cut a legitimately slow on-chain call
|
|
18
|
+
* short — it only bounds a connection nobody is on the other end of.
|
|
19
|
+
*/
|
|
20
|
+
const REQUEST_TIMEOUT_MS = 240_000;
|
|
21
|
+
/**
|
|
22
|
+
* `/api/proof/upload`: its 30 s function cap plus a transfer allowance for a
|
|
23
|
+
* 20 MB body. Mirrors `UPLOAD_CLIENT_ABORT_MS` in src/lib/latency-budget.ts;
|
|
24
|
+
* `client-deadline-mirrors.test.ts` keeps the two equal. `submitProof` had NO
|
|
25
|
+
* deadline before T11.
|
|
26
|
+
*/
|
|
27
|
+
const UPLOAD_CLIENT_ABORT_MS = 120_000;
|
|
28
|
+
/**
|
|
29
|
+
* An API error a caller can branch on (T11-A2): the HTTP status, the stable `code`
|
|
30
|
+
* (see the API's error codes — plus `UPSTREAM_NON_JSON`, which this client sets when
|
|
31
|
+
* the server answered with something that is not JSON), and how long the server
|
|
32
|
+
* asked us to wait (`Retry-After`, else `retry_after_seconds`). Additive: `status`,
|
|
33
|
+
* `body` and the message are what they always were.
|
|
34
|
+
*/
|
|
35
|
+
function makeApiError(status, body, headers) {
|
|
36
|
+
const b = (typeof body === "object" && body !== null ? body : {});
|
|
37
|
+
const message = typeof b.error === "string" ? b.error : `HTTP ${status}`;
|
|
5
38
|
const err = new Error(message);
|
|
6
39
|
err.status = status;
|
|
7
40
|
err.body = body;
|
|
41
|
+
err.code = typeof b.code === "string" ? b.code : null;
|
|
42
|
+
const header = headers?.get("retry-after") ?? null;
|
|
43
|
+
err.retryAfterSeconds =
|
|
44
|
+
header !== null && /^\d+$/.test(header.trim())
|
|
45
|
+
? Number(header.trim())
|
|
46
|
+
: typeof b.retry_after_seconds === "number"
|
|
47
|
+
? b.retry_after_seconds
|
|
48
|
+
: null;
|
|
49
|
+
// The lost-claim 409 (VERIFICATION_IN_PROGRESS) says whether a run is going and
|
|
50
|
+
// where to watch it — `waitForProof(proofId)` is the call to make then.
|
|
51
|
+
if (typeof b.phase === "string")
|
|
52
|
+
err.phase = b.phase;
|
|
53
|
+
if (typeof b.watch_url === "string")
|
|
54
|
+
err.watchUrl = b.watch_url;
|
|
55
|
+
if (typeof b.expected_done_in_seconds === "number")
|
|
56
|
+
err.expectedDoneInSeconds = b.expected_done_in_seconds;
|
|
57
|
+
// A MilestoneEscrowV3 refusal says who can act and how (F-72): the body's actionable
|
|
58
|
+
// fields (G5), then the owner's words, role-aware on the body (H9).
|
|
59
|
+
const detail = v3RefusalDetail(body);
|
|
60
|
+
if (detail && !err.message.includes(detail))
|
|
61
|
+
err.message += ` — ${detail}`;
|
|
62
|
+
const hint = v3HintFor(err.code, body);
|
|
63
|
+
if (hint) {
|
|
64
|
+
err.hint = hint;
|
|
65
|
+
if (!err.message.includes(hint))
|
|
66
|
+
err.message += ` — ${hint}`;
|
|
67
|
+
}
|
|
8
68
|
return err;
|
|
9
69
|
}
|
|
70
|
+
/** A body parsed without trusting it to be JSON: an HTML 502 is "HTTP 502 (non-JSON response from server)". */
|
|
71
|
+
async function readJsonSafe(res) {
|
|
72
|
+
const text = await res.text();
|
|
73
|
+
if (!text)
|
|
74
|
+
return {};
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(text);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return { error: `HTTP ${res.status} (non-JSON response from server)`, code: "UPSTREAM_NON_JSON" };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** True when `value` is the non-JSON marker `readJsonSafe` produced. */
|
|
83
|
+
function isNonJson(value) {
|
|
84
|
+
return typeof value === "object" && value !== null && value.code === "UPSTREAM_NON_JSON";
|
|
85
|
+
}
|
|
86
|
+
// ─── The bar a milestone is judged against ────────────────────────────────────
|
|
87
|
+
// COPY of normalizeMilestoneChecklist / normalizeCriteria in src/lib/milestone-bar-input.ts
|
|
88
|
+
// (a published package cannot import the app). milestone-bar-input-parity.test.ts runs
|
|
89
|
+
// this copy against the owner over the same case matrix.
|
|
90
|
+
const CHECKLIST_MAX_ITEMS = 20;
|
|
91
|
+
const CHECKLIST_TITLE_MAX = 200;
|
|
92
|
+
const CHECKLIST_ID_MAX = 100;
|
|
93
|
+
const CRITERIA_MAX = 4000;
|
|
94
|
+
const CHECKLIST_SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW"];
|
|
95
|
+
function normalizeMilestoneChecklist(items) {
|
|
96
|
+
if (items === undefined || items === null)
|
|
97
|
+
return undefined;
|
|
98
|
+
if (!Array.isArray(items))
|
|
99
|
+
throw new Error("verificationChecklist: must be an array of { title, severity?, id? }");
|
|
100
|
+
if (items.length === 0)
|
|
101
|
+
return undefined;
|
|
102
|
+
if (items.length > CHECKLIST_MAX_ITEMS)
|
|
103
|
+
throw new Error(`verificationChecklist: at most ${CHECKLIST_MAX_ITEMS} items`);
|
|
104
|
+
items.forEach((raw, i) => {
|
|
105
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
106
|
+
throw new Error(`verificationChecklist[${i}]: must be an object with a title`);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
const withId = items.filter((it) => it.id !== undefined).length;
|
|
110
|
+
if (withId !== 0 && withId !== items.length) {
|
|
111
|
+
throw new Error("verificationChecklist: either give every item an id or none (ids are then assigned 1..n)");
|
|
112
|
+
}
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
return items.map((raw, i) => {
|
|
115
|
+
const it = raw;
|
|
116
|
+
const title = typeof it.title === "string" ? it.title.trim() : "";
|
|
117
|
+
if (title.length === 0)
|
|
118
|
+
throw new Error(`verificationChecklist[${i}]: title is required`);
|
|
119
|
+
if (title.length > CHECKLIST_TITLE_MAX)
|
|
120
|
+
throw new Error(`verificationChecklist[${i}]: title must be at most ${CHECKLIST_TITLE_MAX} characters`);
|
|
121
|
+
let id = i + 1;
|
|
122
|
+
if (withId) {
|
|
123
|
+
if (typeof it.id !== "number" || !Number.isInteger(it.id) || it.id < 1 || it.id > CHECKLIST_ID_MAX) {
|
|
124
|
+
throw new Error(`verificationChecklist[${i}]: id must be an integer from 1 to ${CHECKLIST_ID_MAX}`);
|
|
125
|
+
}
|
|
126
|
+
if (seen.has(it.id))
|
|
127
|
+
throw new Error(`verificationChecklist[${i}]: duplicate id ${it.id}`);
|
|
128
|
+
seen.add(it.id);
|
|
129
|
+
id = it.id;
|
|
130
|
+
}
|
|
131
|
+
const out = { id, title };
|
|
132
|
+
if (it.severity !== undefined && it.severity !== null && it.severity !== "") {
|
|
133
|
+
const sev = typeof it.severity === "string" ? it.severity.trim().toUpperCase() : "";
|
|
134
|
+
if (!CHECKLIST_SEVERITIES.includes(sev)) {
|
|
135
|
+
throw new Error(`verificationChecklist[${i}]: severity must be one of ${CHECKLIST_SEVERITIES.join(", ")}`);
|
|
136
|
+
}
|
|
137
|
+
out.severity = sev;
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
function normalizeCriteria(value) {
|
|
143
|
+
if (value === undefined || value === null)
|
|
144
|
+
return undefined;
|
|
145
|
+
if (typeof value !== "string")
|
|
146
|
+
throw new Error("verificationCriteria: must be a string");
|
|
147
|
+
const v = value.trim();
|
|
148
|
+
if (v.length === 0)
|
|
149
|
+
return undefined;
|
|
150
|
+
if (v.length > CRITERIA_MAX)
|
|
151
|
+
throw new Error(`verificationCriteria: at most ${CRITERIA_MAX} characters (got ${v.length})`);
|
|
152
|
+
return v;
|
|
153
|
+
}
|
|
10
154
|
/**
|
|
11
155
|
* Typed client for the Cascrow API. Wraps the same REST endpoints used by
|
|
12
156
|
* the cascrow-mcp server and the Cascrow web app — this is the plain-code
|
|
13
157
|
* equivalent for applications that don't go through an MCP/LLM tool call.
|
|
14
158
|
*/
|
|
159
|
+
/** Every `action` the verification stream can send. Kept next to the check so the
|
|
160
|
+
* two cannot drift: adding a value to `VerificationResult["action"]` without adding
|
|
161
|
+
* it here makes this file fail to compile. */
|
|
162
|
+
const STREAM_ACTIONS = ["VERIFIED", "PENDING_REVIEW", "REJECTED", "COMPLETED", "SUPERSEDED", "V3_RECOMMENDATION", "NOT_COMPLETABLE_WITHOUT_ESCROW"];
|
|
163
|
+
/**
|
|
164
|
+
* The stream's `action`, or a thrown error — never an invented verdict.
|
|
165
|
+
*
|
|
166
|
+
* SEC-059. This used to be `(msg.action as VerificationResult["action"]) ?? "REJECTED"`,
|
|
167
|
+
* twice. Two things were wrong with it. The cast accepted `COMPLETED` and `SUPERSEDED`
|
|
168
|
+
* — real values the stream sends — while the declared union listed neither, so the
|
|
169
|
+
* returned object was outside its own type and nothing said so. And the fallback
|
|
170
|
+
* invented `REJECTED` for an absent field: a missing `action` is a protocol violation,
|
|
171
|
+
* not a rejection, and defaulting it in the one direction that costs the builder their
|
|
172
|
+
* reputation is the worst available choice. Every `complete` frame the route sends
|
|
173
|
+
* carries an action, so this cannot fire against a healthy server; when it does fire,
|
|
174
|
+
* it throws the way a malformed frame already does rather than guessing.
|
|
175
|
+
*/
|
|
176
|
+
function requireAction(value) {
|
|
177
|
+
if (typeof value === "string" && STREAM_ACTIONS.includes(value)) {
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
180
|
+
throw new Error(`Verification stream sent an action this client does not know: ${JSON.stringify(value)}. ` +
|
|
181
|
+
`Expected one of ${STREAM_ACTIONS.join(", ")} — upgrade cascrow-sdk.`);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The terminal status of the row a proof settles against, or null while it is open.
|
|
185
|
+
* `milestoneClosed` is derived from the row by `/api/agent/proof-status`;
|
|
186
|
+
* `milestoneStatus` is the fallback for a server that predates the field.
|
|
187
|
+
*/
|
|
188
|
+
function closedMilestoneStatus(s) {
|
|
189
|
+
if (s.milestoneClosed === true)
|
|
190
|
+
return s.terminalStatus || s.milestoneStatus || s.contractStatus || "EXPIRED";
|
|
191
|
+
// A legacy contract-level proof has no milestone: an older server reports its row only
|
|
192
|
+
// as `contractStatus`, and reading `milestoneStatus` alone reported a proof whose
|
|
193
|
+
// contract had EXPIRED as open.
|
|
194
|
+
const row = s.milestoneStatus ?? s.contractStatus;
|
|
195
|
+
return row === "EXPIRED" || row === "COMPLETED" ? row : null;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* True when the milestone is approved (VERIFIED) or paid (COMPLETED) and this proof has
|
|
199
|
+
* no YES of its own: another proof won it, or the funder approved it in review — which
|
|
200
|
+
* leaves this proof's `aiDecision` untouched (NO, or null after a panel outage). Nothing
|
|
201
|
+
* is left for this proof to decide: `/api/verify`'s claim excludes VERIFIED and
|
|
202
|
+
* COMPLETED for every verdict, so a pending proof there never gets one, and the loop
|
|
203
|
+
* below used to burn its whole timeout on it. Same predicate as both MCP surfaces.
|
|
204
|
+
*/
|
|
205
|
+
function approvedWithoutThisVerdict(s) {
|
|
206
|
+
// Except while this proof's panel is RUNNING on a VERIFIED row. VERIFIED is not sticky:
|
|
207
|
+
// a definite release failure rolls it back, `contracts/review` hands it back to
|
|
208
|
+
// PENDING_REVIEW and the cron's hand-back blocks do the same, and a running claim then
|
|
209
|
+
// decides the row after all. COMPLETED is terminal and stays final either way.
|
|
210
|
+
// `running` is absent on a server that predates it, which keeps the old reading.
|
|
211
|
+
const row = closedMilestoneStatus(s) === "COMPLETED" ? "COMPLETED" : (s.milestoneStatus ?? s.contractStatus);
|
|
212
|
+
if (s.decision === "YES")
|
|
213
|
+
return false;
|
|
214
|
+
return row === "COMPLETED" || (row === "VERIFIED" && s.running !== true);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Parked for a human: no verdict, the milestone at `PENDING_REVIEW`, and no panel running
|
|
218
|
+
* on this proof. A RUNNING proof on a PENDING_REVIEW row is not parked — a sibling proof
|
|
219
|
+
* in the 60-85 band put the milestone there while this one's panel still runs, and this
|
|
220
|
+
* one can still take it to COMPLETED. Same test as `parkedForHuman` at both MCP surfaces;
|
|
221
|
+
* without `running` (an older server) the old reading stays.
|
|
222
|
+
*/
|
|
223
|
+
function parkedForHuman(s) {
|
|
224
|
+
// Never a MilestoneEscrowV3 proof (post-review L2): no human review decides one — the
|
|
225
|
+
// milestone's status is the fold's, from the chain, and a PENDING_REVIEW there is no
|
|
226
|
+
// queue this proof waits in.
|
|
227
|
+
return s.escrowVersion !== 3 && Boolean(s.pending) && s.running !== true && s.milestoneStatus === "PENDING_REVIEW";
|
|
228
|
+
}
|
|
15
229
|
export class CascrowClient {
|
|
16
230
|
apiKey;
|
|
17
231
|
baseUrl;
|
|
@@ -19,22 +233,123 @@ export class CascrowClient {
|
|
|
19
233
|
this.apiKey = options.apiKey;
|
|
20
234
|
this.baseUrl = options.baseUrl ?? "https://cascrow.com";
|
|
21
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* MilestoneEscrowV3 — the remote half. cascrow prepares and reads; YOU sign and
|
|
238
|
+
* send with your own key. This SDK never signs: use the separate, pinned
|
|
239
|
+
* `cascrow-signer` package (`npx cascrow-signer@<pin> …`, see CASCROW_SIGNER_PIN),
|
|
240
|
+
* which checks every prepared transaction against its own chain and contract.
|
|
241
|
+
*/
|
|
242
|
+
v3 = {
|
|
243
|
+
/** As PAYER: offer terms for a milestone. 409 `OFFER_EXISTS` names the open offer. */
|
|
244
|
+
offer: (input) => this.request("POST", "/api/escrow/v3/offer", input),
|
|
245
|
+
/** As PAYEE: the frozen offer to check and sign (EIP-712). 409 `V3_NOT_OFFERED` before the payer offered. */
|
|
246
|
+
acceptance: (milestoneId) => this.request("GET", `/api/escrow/v3/acceptance?milestoneId=${encodeURIComponent(milestoneId)}`),
|
|
247
|
+
/** As PAYEE: hand over the acceptance signature — binding until acceptBy. */
|
|
248
|
+
submitAcceptance: (offerId, signature) => this.request("POST", "/api/escrow/v3/acceptance", { offerId, signature }),
|
|
249
|
+
/** An UNSIGNED transaction. Rebuild `data` and check `to`/`chainId` yourself before signing. */
|
|
250
|
+
prepare: (milestoneId, action, extra = {}) => {
|
|
251
|
+
const q = new URLSearchParams({ milestoneId, action });
|
|
252
|
+
if (extra.proofHash)
|
|
253
|
+
q.set("proofHash", extra.proofHash);
|
|
254
|
+
if (extra.reasonHash)
|
|
255
|
+
q.set("reasonHash", extra.reasonHash);
|
|
256
|
+
if (extra.newDeadline !== undefined)
|
|
257
|
+
q.set("newDeadline", String(extra.newDeadline));
|
|
258
|
+
return this.request("GET", `/api/escrow/v3/prepare?${q.toString()}`);
|
|
259
|
+
},
|
|
260
|
+
/** After YOU broadcast: the hash as a hint to re-read the chain. Only a hash — never a signed transaction. */
|
|
261
|
+
reportTx: (milestoneId, txHash) => {
|
|
262
|
+
// Refused before any request, with the same stable code both MCP surfaces use (L3).
|
|
263
|
+
if (!TX_HASH_RE.test(txHash))
|
|
264
|
+
return Promise.reject(makeApiError(0, { error: V3_CLIENT_TEXT.reportTxOnlyHash, code: "NOT_A_TX_HASH" }));
|
|
265
|
+
return this.request("POST", "/api/escrow/v3/sync", { milestoneId, txHash });
|
|
266
|
+
},
|
|
267
|
+
/** cascrow's signed, NON-BINDING AI recommendation for a claim. 409 `ATTESTATION_PENDING` while the panel runs. */
|
|
268
|
+
attestation: (escrowId, claimIndex) => this.request("GET", `/api/escrow/v3/attestation?${new URLSearchParams({ escrowId, claimIndex: String(claimIndex) }).toString()}`),
|
|
269
|
+
/** As PAYER: open claims you can still object to (same as `v3Claims()`). */
|
|
270
|
+
claims: () => this.request("GET", "/api/agent/v3-claims"),
|
|
271
|
+
};
|
|
22
272
|
authHeaders() {
|
|
23
273
|
return this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {};
|
|
24
274
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
275
|
+
/**
|
|
276
|
+
* Every non-streaming call carries a deadline (SEC-073).
|
|
277
|
+
*
|
|
278
|
+
* Before this, the only `AbortController` in the SDK was inside `verify()`, the
|
|
279
|
+
* streaming method — so `verifyWork()`, which waits on a route the platform
|
|
280
|
+
* kills at 120 s, waited forever and then surfaced a torn connection. The
|
|
281
|
+
* default is the longest cap any route in this API declares (240 s), so it
|
|
282
|
+
* cannot cut a legitimately slow on-chain call short; `verifyWork` passes the
|
|
283
|
+
* tighter one because its own route is capped lower.
|
|
284
|
+
*
|
|
285
|
+
* An `AbortError` is re-thrown as a sentence, not as the DOM error: a caller
|
|
286
|
+
* cannot tell "we stopped waiting" from "the panel said no" out of an
|
|
287
|
+
* exception whose message is "This operation was aborted" (F-72).
|
|
288
|
+
*/
|
|
289
|
+
async request(method, path, body, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
290
|
+
const controller = new AbortController();
|
|
291
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
292
|
+
const isForm = typeof FormData !== "undefined" && body instanceof FormData;
|
|
293
|
+
let res;
|
|
294
|
+
try {
|
|
295
|
+
res = await fetch(`${this.baseUrl}${path}`, {
|
|
296
|
+
method,
|
|
297
|
+
headers: {
|
|
298
|
+
...this.authHeaders(),
|
|
299
|
+
...(body !== undefined && !isForm ? { "Content-Type": "application/json" } : {}),
|
|
300
|
+
},
|
|
301
|
+
body: body === undefined ? undefined : isForm ? body : JSON.stringify(body),
|
|
302
|
+
signal: controller.signal,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
307
|
+
throw new Error(`cascrow did not answer ${method} ${path} within ${Math.round(timeoutMs / 1000)}s. ` +
|
|
308
|
+
"This client stopped waiting; the server may still be working on it.");
|
|
309
|
+
}
|
|
310
|
+
clearTimeout(timer);
|
|
311
|
+
throw err;
|
|
312
|
+
}
|
|
313
|
+
// Cleared AFTER the body, not in a `finally` around the header fetch: a
|
|
314
|
+
// server that sends headers and then stalls the body was otherwise
|
|
315
|
+
// unbounded, and the promise below is what the caller actually waits on.
|
|
316
|
+
// The streaming path in this same file learned this already — its abort
|
|
317
|
+
// "only became load-bearing when the clearTimeout moved below the read loop".
|
|
318
|
+
try {
|
|
319
|
+
let data;
|
|
320
|
+
try {
|
|
321
|
+
data = await readJsonSafe(res);
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
// The deadline can fire during the body read too; say so the same way as
|
|
325
|
+
// at the header fetch, with the code a caller branches on.
|
|
326
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
327
|
+
const e = new Error(`cascrow did not finish answering ${method} ${path} within ${Math.round(timeoutMs / 1000)}s. ` +
|
|
328
|
+
"This client stopped waiting; the server may still be working on it.");
|
|
329
|
+
e.status = res.status;
|
|
330
|
+
e.body = null;
|
|
331
|
+
e.code = "CLIENT_TIMEOUT";
|
|
332
|
+
e.retryAfterSeconds = null;
|
|
333
|
+
throw e;
|
|
334
|
+
}
|
|
335
|
+
throw err;
|
|
336
|
+
}
|
|
337
|
+
// A 200 that is not JSON is not a success anyone can read.
|
|
338
|
+
if (!res.ok || isNonJson(data)) {
|
|
339
|
+
const err = makeApiError(res.status, data, res.headers);
|
|
340
|
+
// A platform error page on a POST (the 504 when a route hits its own
|
|
341
|
+
// maxDuration — escrow/finish waits on the chain) does not mean the call failed: the
|
|
342
|
+
// function may have moved money before it was killed.
|
|
343
|
+
if (method !== "GET" && res.status >= 500 && isNonJson(data)) {
|
|
344
|
+
err.message += ". The request may still have completed on the server — check the contract (getContract) before repeating it.";
|
|
345
|
+
}
|
|
346
|
+
throw err;
|
|
347
|
+
}
|
|
348
|
+
return data;
|
|
349
|
+
}
|
|
350
|
+
finally {
|
|
351
|
+
clearTimeout(timer);
|
|
352
|
+
}
|
|
38
353
|
}
|
|
39
354
|
// ─── Auth ───────────────────────────────────────────────────────────────────
|
|
40
355
|
/** Self-register an agent account. No CAPTCHA, no email verification. Returns an API key — save it, it is shown only once. */
|
|
@@ -57,11 +372,27 @@ export class CascrowClient {
|
|
|
57
372
|
return d.toISOString();
|
|
58
373
|
};
|
|
59
374
|
const data = await this.request("POST", "/api/contracts", {
|
|
60
|
-
milestones: input.milestones.map((m) =>
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
375
|
+
milestones: input.milestones.map((m, i) => {
|
|
376
|
+
// T07. Normalised here so a bad item is refused with its milestone and
|
|
377
|
+
// item index, not as a 400 on the whole contract. Nothing given → the
|
|
378
|
+
// body is what it always was.
|
|
379
|
+
let criteria;
|
|
380
|
+
let checklist;
|
|
381
|
+
try {
|
|
382
|
+
criteria = normalizeCriteria(m.verificationCriteria);
|
|
383
|
+
checklist = normalizeMilestoneChecklist(m.verificationChecklist);
|
|
384
|
+
}
|
|
385
|
+
catch (e) {
|
|
386
|
+
throw new Error(`milestones[${i}]: ${e instanceof Error ? e.message : String(e)}`);
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
title: m.title,
|
|
390
|
+
amountUSD: m.amountUSD ?? 0,
|
|
391
|
+
cancelAfter: cancelAfter(m.daysUntilDeadline),
|
|
392
|
+
...(criteria !== undefined ? { verificationCriteria: criteria } : {}),
|
|
393
|
+
...(checklist !== undefined ? { verificationChecklist: checklist } : {}),
|
|
394
|
+
};
|
|
395
|
+
}),
|
|
65
396
|
agentReviewMode: input.agentReviewMode,
|
|
66
397
|
});
|
|
67
398
|
return {
|
|
@@ -79,13 +410,76 @@ export class CascrowClient {
|
|
|
79
410
|
const qs = options.role ? `?role=${encodeURIComponent(options.role)}` : "";
|
|
80
411
|
return this.request("GET", `/api/agent/my-contracts${qs}`);
|
|
81
412
|
}
|
|
82
|
-
/**
|
|
413
|
+
/**
|
|
414
|
+
* Join an existing contract as the Builder using an invite code.
|
|
415
|
+
*
|
|
416
|
+
* `activated: true` — a $0 verification contract: it is active (`status: "FUNDED"`)
|
|
417
|
+
* and you can submit proof now. `activated: false` — the milestones carry an amount:
|
|
418
|
+
* wait for the payer's MilestoneEscrowV3 offer and accept it with your own wallet key
|
|
419
|
+
* (`npx cascrow-signer@0.1.0 accept --milestone <id>`, or `client.v3.acceptance` +
|
|
420
|
+
* `client.v3.submitAcceptance`); proof is accepted once the payer has funded it.
|
|
421
|
+
* `joinedLine(result)` renders that as one sentence.
|
|
422
|
+
*/
|
|
83
423
|
async joinContract(inviteCode) {
|
|
84
424
|
return this.request("POST", "/api/contracts/join", { inviteCode });
|
|
85
425
|
}
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
|
|
426
|
+
/**
|
|
427
|
+
* Approve or reject a borderline (PENDING_REVIEW) MILESTONE. Only valid when
|
|
428
|
+
* agentReviewMode is MANUAL or MANUAL_AUTO.
|
|
429
|
+
*
|
|
430
|
+
* The CONTRACT row need not be PENDING_REVIEW: funding a later milestone
|
|
431
|
+
* overwrites that column, and such a contract is still reviewable. Only a
|
|
432
|
+
* terminal contract (COMPLETED, EXPIRED, DECLINED) is refused.
|
|
433
|
+
*
|
|
434
|
+
* `milestoneId` is optional while exactly one milestone of the contract is
|
|
435
|
+
* pending review and REQUIRED once more than one is — the API answers 400
|
|
436
|
+
* "Multiple milestones are pending review — specify milestoneId to
|
|
437
|
+
* disambiguate". Until this parameter existed an SDK caller had no way to
|
|
438
|
+
* comply with that refusal, which is exactly the case SEC-076 is about: a
|
|
439
|
+
* multi-milestone contract where a second proof lands while the first is
|
|
440
|
+
* still under review (SEC-120, F-72).
|
|
441
|
+
*
|
|
442
|
+
* Additive optional third argument — every existing call site still compiles.
|
|
443
|
+
*/
|
|
444
|
+
async reviewContract(contractId, decision, milestoneId) {
|
|
445
|
+
return this.request("POST", "/api/contracts/review", milestoneId ? { contractId, decision, milestoneId } : { contractId, decision });
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* The contracts on which THIS agent, as funder, still owes a manual verdict.
|
|
449
|
+
*
|
|
450
|
+
* The pull behind the `manual_review.required` webhook. A webhook that does
|
|
451
|
+
* not arrive — the agent was down, the endpoint 500'd, the subscription was
|
|
452
|
+
* never made — is a decision that never gets made, and `cron/cancel-expired`
|
|
453
|
+
* then completes the milestone after 48 h (agent contracts in MANUAL_AUTO or
|
|
454
|
+
* AUTO) or 14 days (every other contract) — verification-only contracts, so
|
|
455
|
+
* no money moves, and nobody decided it. The whole point of the 60–85 confidence
|
|
456
|
+
* band is that somebody looks.
|
|
457
|
+
*
|
|
458
|
+
* Every browser surface the funder has for this asks
|
|
459
|
+
* `funderReviewQueueWhere()`; so does this route, so the queue an agent polls
|
|
460
|
+
* and the queue `reviewContract` accepts a verdict on cannot come apart.
|
|
461
|
+
*
|
|
462
|
+
* `reviewMilestoneId` is what to pass to {@link reviewContract}: non-null when
|
|
463
|
+
* exactly one milestone of that contract is pending, `null` for the legacy
|
|
464
|
+
* contract-level population that is reviewed by `contractId` alone. When
|
|
465
|
+
* `pendingMilestones` holds more than one entry the milestone has to be chosen
|
|
466
|
+
* — `POST /api/contracts/review` answers 400 otherwise.
|
|
467
|
+
*
|
|
468
|
+
* `truncated` says the list is capped while `total` is not, so a poller does
|
|
469
|
+
* not read a short list as "that was all".
|
|
470
|
+
*/
|
|
471
|
+
async reviewQueue() {
|
|
472
|
+
return this.request("GET", "/api/agent/review-queue");
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* As the PAYER of a MilestoneEscrowV3 escrow: the claims that release it to the
|
|
476
|
+
* builder unless your own wallet calls `object(escrowId)` before
|
|
477
|
+
* `objectionDeadline` — the pull behind the `claim.submitted` webhook. cascrow
|
|
478
|
+
* cannot object for you. Throws with code `V3_INDEX_STALE` (503) while the index
|
|
479
|
+
* is not current; read the chain directly then.
|
|
480
|
+
*/
|
|
481
|
+
async v3Claims() {
|
|
482
|
+
return this.request("GET", "/api/agent/v3-claims");
|
|
89
483
|
}
|
|
90
484
|
/** Check for pending contract invites sent to this agent. */
|
|
91
485
|
async checkInvites() {
|
|
@@ -96,29 +490,27 @@ export class CascrowClient {
|
|
|
96
490
|
return this.request("POST", "/api/agent/handoff", input);
|
|
97
491
|
}
|
|
98
492
|
// ─── Funding ────────────────────────────────────────────────────────────────
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
493
|
+
// `fundMilestone()` was removed in 0.7.0 (cascrow Phase 6, owner decision 16): a
|
|
494
|
+
// $0 verification contract is active by itself once the builder has joined, and a
|
|
495
|
+
// milestone with an amount is a MilestoneEscrowV3 escrow its payer funds with one
|
|
496
|
+
// transaction from their own machine (`npx cascrow-signer fund --milestone <id>`,
|
|
497
|
+
// after `client.v3.offer` and the builder's acceptance). The SDK signs nothing.
|
|
498
|
+
//
|
|
499
|
+
// `escrowFund()` was removed in 0.5.0 (cascrow Phase 5): it sent the agent's
|
|
500
|
+
// private key to cascrow, which signed the deposit server-side. cascrow never
|
|
501
|
+
// accepts, creates, stores or signs with anyone's key; the server answers the old
|
|
502
|
+
// path with 410 CUSTODY_PATH_REMOVED.
|
|
107
503
|
// ─── Proof & verification ───────────────────────────────────────────────────
|
|
108
|
-
/**
|
|
504
|
+
/**
|
|
505
|
+
* Submit a text proof report for a milestone. Verification starts by itself —
|
|
506
|
+
* wait for the verdict with `waitForProof(proofId)`; do not call `verify()` for it,
|
|
507
|
+
* that run is already going.
|
|
508
|
+
*/
|
|
109
509
|
async submitProof(input) {
|
|
110
510
|
const form = new FormData();
|
|
111
511
|
form.append("milestoneId", input.milestoneId);
|
|
112
512
|
form.append("file", new Blob([input.content], { type: "text/plain" }), input.filename ?? "proof-report.txt");
|
|
113
|
-
|
|
114
|
-
method: "POST",
|
|
115
|
-
headers: this.authHeaders(),
|
|
116
|
-
body: form,
|
|
117
|
-
});
|
|
118
|
-
const data = await res.json().catch(() => ({}));
|
|
119
|
-
if (!res.ok)
|
|
120
|
-
throw makeApiError(res.status, data);
|
|
121
|
-
return data;
|
|
513
|
+
return this.request("POST", "/api/proof/upload", form, UPLOAD_CLIENT_ABORT_MS);
|
|
122
514
|
}
|
|
123
515
|
/** Check the AI verification result for a submitted proof (poll after submitProof). */
|
|
124
516
|
async getProofStatus(proofId) {
|
|
@@ -137,7 +529,10 @@ export class CascrowClient {
|
|
|
137
529
|
async verify(proofId, options = {}) {
|
|
138
530
|
const resolveOn = options.resolveOn ?? "complete";
|
|
139
531
|
const controller = new AbortController();
|
|
140
|
-
|
|
532
|
+
// SEC-073: 235_000, and it only became load-bearing when the clearTimeout
|
|
533
|
+
// moved below the read loop — see src/lib/latency-budget.ts:CLIENT_ABORT_MS.
|
|
534
|
+
// Kept as a numeric literal because this package cannot import from src/.
|
|
535
|
+
const timeout = setTimeout(() => controller.abort(), CLIENT_ABORT_MS);
|
|
141
536
|
let res;
|
|
142
537
|
try {
|
|
143
538
|
res = await fetch(`${this.baseUrl}/api/verify`, {
|
|
@@ -147,12 +542,28 @@ export class CascrowClient {
|
|
|
147
542
|
signal: controller.signal,
|
|
148
543
|
});
|
|
149
544
|
}
|
|
150
|
-
|
|
545
|
+
catch (e) {
|
|
151
546
|
clearTimeout(timeout);
|
|
547
|
+
if (e instanceof Error && e.name === "AbortError") {
|
|
548
|
+
throw new Error(`Verification is still running on the server — this client stopped waiting, it did not fail. Poll GET /api/agent/proof-status/${proofId} for the verdict.`);
|
|
549
|
+
}
|
|
550
|
+
throw e;
|
|
152
551
|
}
|
|
153
|
-
if (!res.ok
|
|
154
|
-
|
|
155
|
-
|
|
552
|
+
if (!res.ok) {
|
|
553
|
+
clearTimeout(timeout);
|
|
554
|
+
// A 409 with code VERIFICATION_IN_PROGRESS and phase "running" means a run is
|
|
555
|
+
// already going (usually the one upload started): the error carries `phase`,
|
|
556
|
+
// `watchUrl` and `expectedDoneInSeconds` — call `waitForProof(proofId)`. Its
|
|
557
|
+
// `retryAfterSeconds` is the ~1 h takeover cutoff, NOT a backoff.
|
|
558
|
+
throw makeApiError(res.status, await readJsonSafe(res), res.headers);
|
|
559
|
+
}
|
|
560
|
+
// Split from the line above, which was `!res.ok || !res.body`. A 200 with no
|
|
561
|
+
// body then produced `makeApiError(200, {})` — an "API error" carrying a
|
|
562
|
+
// success status, and the one door of six without the separate branch the
|
|
563
|
+
// other five have.
|
|
564
|
+
if (!res.body) {
|
|
565
|
+
clearTimeout(timeout);
|
|
566
|
+
throw new Error("Verification stream had no body");
|
|
156
567
|
}
|
|
157
568
|
const votes = [];
|
|
158
569
|
const reader = res.body.getReader();
|
|
@@ -186,20 +597,60 @@ export class CascrowClient {
|
|
|
186
597
|
return {
|
|
187
598
|
decision: msg.decision ?? "NO",
|
|
188
599
|
confidence: msg.confidence ?? 0,
|
|
189
|
-
action: msg.action
|
|
600
|
+
action: requireAction(msg.action),
|
|
190
601
|
reasoning: msg.reasoning ?? "",
|
|
191
602
|
modelVotes: votes,
|
|
192
603
|
txHash: null,
|
|
193
604
|
};
|
|
605
|
+
// `reasoning` here is MODEL OUTPUT over evidence the counterparty
|
|
606
|
+
// submitted, and it reaches you RAW. The MCP servers prefix it with an
|
|
607
|
+
// explicit untrusted-content marker; this SDK does not, deliberately —
|
|
608
|
+
// its return value may equally end up in a UI, where a marker is noise
|
|
609
|
+
// on a page, and a library cannot know which.
|
|
610
|
+
//
|
|
611
|
+
// That asymmetry was justified in the app for a while with a sentence
|
|
612
|
+
// that is FALSE of this file: "an agent reaching the SSE frames does so
|
|
613
|
+
// through an MCP server that marks at its own end". A review pass found
|
|
614
|
+
// this door — `verify()`/`verifyAndWait` hand the field to a
|
|
615
|
+
// TypeScript agent with no MCP server in the path. The decision is
|
|
616
|
+
// unchanged; the reason is now stated where it is true. If you feed this
|
|
617
|
+
// into a model, treat it as data and not as instructions.
|
|
194
618
|
}
|
|
195
619
|
else if (msg.type === "complete") {
|
|
196
620
|
return {
|
|
197
621
|
decision: msg.decision ?? "NO",
|
|
198
622
|
confidence: msg.confidence ?? 0,
|
|
199
|
-
action: msg.action
|
|
623
|
+
action: requireAction(msg.action),
|
|
200
624
|
reasoning: msg.reasoning ?? "",
|
|
201
625
|
modelVotes: votes,
|
|
202
626
|
txHash: msg.txHash ?? null,
|
|
627
|
+
// Forwarded because `action` alone does not answer "did the money move":
|
|
628
|
+
// a VERIFIED with a hold or a compliance block did not, and a COMPLETED
|
|
629
|
+
// that someone else released did. SEC-058 / SEC-059.
|
|
630
|
+
...(msg.alreadyReleased === true ? { alreadyReleased: true } : {}),
|
|
631
|
+
// The panel approved a milestone nothing can complete (an amount without a
|
|
632
|
+
// MilestoneEscrowV3 escrow): the server's sentence and remedy, forwarded.
|
|
633
|
+
...(msg.action === "NOT_COMPLETABLE_WITHOUT_ESCROW"
|
|
634
|
+
? {
|
|
635
|
+
...(typeof msg.error === "string" ? { error: msg.error } : {}),
|
|
636
|
+
...(typeof msg.remedy === "string" ? { remedy: msg.remedy } : {}),
|
|
637
|
+
}
|
|
638
|
+
: {}),
|
|
639
|
+
...(msg.complianceBlocked === true ? { complianceBlocked: true } : {}),
|
|
640
|
+
...(msg.timedOut === true ? { timedOut: true } : {}),
|
|
641
|
+
// With `action: "SUPERSEDED"`: the milestone was closed (e.g. EXPIRED)
|
|
642
|
+
// before this verification could settle it, rather than decided by
|
|
643
|
+
// another proof.
|
|
644
|
+
...(typeof msg.settledAs === "string" ? { settledAs: msg.settledAs } : {}),
|
|
645
|
+
// MilestoneEscrowV3: the claim-relevant answer is the SUBMISSION's verdict,
|
|
646
|
+
// not `decision` (the panel on this one file). Absent → PENDING, never YES.
|
|
647
|
+
...(msg.action === "V3_RECOMMENDATION"
|
|
648
|
+
? {
|
|
649
|
+
manifestHash: typeof msg.manifestHash === "string" ? msg.manifestHash : null,
|
|
650
|
+
submissionVerdict: msg.submissionVerdict === "YES" || msg.submissionVerdict === "NO" ? msg.submissionVerdict : "PENDING",
|
|
651
|
+
claimReady: msg.claimReady === true,
|
|
652
|
+
}
|
|
653
|
+
: {}),
|
|
203
654
|
};
|
|
204
655
|
}
|
|
205
656
|
else if (msg.type === "error") {
|
|
@@ -207,15 +658,35 @@ export class CascrowClient {
|
|
|
207
658
|
}
|
|
208
659
|
}
|
|
209
660
|
}
|
|
661
|
+
// SEC-073. The abort timer is cleared HERE, after the read loop — not in a
|
|
662
|
+
// `finally` around the `fetch`. `fetch` resolves on response HEADERS, about
|
|
663
|
+
// 12 s into `/api/verify`, and the entire 205 s retry ladder arrives
|
|
664
|
+
// afterwards out of `res.body`. Clearing it at the fetch made this a
|
|
665
|
+
// time-to-first-byte deadline over a twelve-second phase, so the number
|
|
666
|
+
// bounded nothing that mattered and could sit below the server's worst
|
|
667
|
+
// case unnoticed.
|
|
668
|
+
}
|
|
669
|
+
catch (e) {
|
|
670
|
+
// SEC-073. Live deadline, so this can fire mid-read; and per
|
|
671
|
+
// latency-budget.ts the abort can land after releaseMilestone(), which
|
|
672
|
+
// makes a bare "aborted" not merely unhelpful but possibly false about
|
|
673
|
+
// whether the money moved.
|
|
674
|
+
if (e instanceof Error && e.name === "AbortError") {
|
|
675
|
+
throw new Error(`Verification is still running on the server — this client stopped waiting, it did not fail. Poll GET /api/agent/proof-status/${proofId} for the verdict.`);
|
|
676
|
+
}
|
|
677
|
+
throw e;
|
|
210
678
|
}
|
|
211
679
|
finally {
|
|
680
|
+
clearTimeout(timeout);
|
|
212
681
|
reader.cancel().catch(() => { });
|
|
213
682
|
}
|
|
214
683
|
throw new Error("Verification stream ended without a complete event");
|
|
215
684
|
}
|
|
216
685
|
/**
|
|
217
686
|
* Submit proof and get an immediate verdict in one call — runs the full
|
|
218
|
-
* 5-model pipeline
|
|
687
|
+
* 5-model pipeline. On approval a verification-only milestone completes and its NFT
|
|
688
|
+
* is minted; for a MilestoneEscrowV3 milestone the verdict is a recommendation
|
|
689
|
+
* (`verdict: "v3_recommendation"`) and moves no money.
|
|
219
690
|
*/
|
|
220
691
|
async mcpSubmit(input) {
|
|
221
692
|
const data = await this.request("POST", "/api/mcp/submit", {
|
|
@@ -229,12 +700,34 @@ export class CascrowClient {
|
|
|
229
700
|
custom_fields: input.evidence.customFields,
|
|
230
701
|
},
|
|
231
702
|
});
|
|
703
|
+
// Ordered so the two verdict-less responses are classified before the field that
|
|
704
|
+
// is missing on both is read. A `?? "rejected"` here would be the same
|
|
705
|
+
// wrong-direction default `requireAction` above was written to remove.
|
|
706
|
+
const verdict = data.action === "V3_RECOMMENDATION"
|
|
707
|
+
? "v3_recommendation"
|
|
708
|
+
: data.decided === "elsewhere"
|
|
709
|
+
? "decided_elsewhere"
|
|
710
|
+
: data.panelUnavailable
|
|
711
|
+
? "panel_unavailable"
|
|
712
|
+
: data.verdict;
|
|
713
|
+
if (!verdict) {
|
|
714
|
+
throw new Error("mcp/submit answered 200 with no verdict and no known refusal state — upgrade cascrow-sdk.");
|
|
715
|
+
}
|
|
232
716
|
return {
|
|
233
|
-
verdict
|
|
234
|
-
confidence: data.confidence,
|
|
235
|
-
reasoning: data.reasoning,
|
|
236
|
-
onChainUrl: data.on_chain_url,
|
|
237
|
-
proofId: data.proof_id,
|
|
717
|
+
verdict,
|
|
718
|
+
confidence: data.confidence ?? null,
|
|
719
|
+
reasoning: data.reasoning ?? "",
|
|
720
|
+
onChainUrl: data.on_chain_url ?? null,
|
|
721
|
+
proofId: data.proof_id ?? null,
|
|
722
|
+
settledAs: verdict === "decided_elsewhere" && typeof data.settledAs === "string" ? data.settledAs : null,
|
|
723
|
+
...(verdict === "v3_recommendation"
|
|
724
|
+
? {
|
|
725
|
+
decision: data.decision ?? null,
|
|
726
|
+
manifestHash: data.manifestHash ?? null,
|
|
727
|
+
submissionVerdict: data.submissionVerdict === "YES" || data.submissionVerdict === "NO" ? data.submissionVerdict : "PENDING",
|
|
728
|
+
claimReady: data.claimReady === true,
|
|
729
|
+
}
|
|
730
|
+
: {}),
|
|
238
731
|
};
|
|
239
732
|
}
|
|
240
733
|
/**
|
|
@@ -245,34 +738,163 @@ export class CascrowClient {
|
|
|
245
738
|
* yet, so do not treat a key as a way to get more runs.
|
|
246
739
|
*/
|
|
247
740
|
async verifyWork(input) {
|
|
248
|
-
return this.request("POST", "/api/verify/standalone", input);
|
|
741
|
+
return this.request("POST", "/api/verify/standalone", input, STANDALONE_CLIENT_ABORT_MS);
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* True when a thrown `CascrowApiError` means "cascrow could not run the panel",
|
|
745
|
+
* not "the work was not verified" (SEC-128).
|
|
746
|
+
*
|
|
747
|
+
* `verifyWork` resolves ONLY when a verdict exists. When fewer than three of the
|
|
748
|
+
* five models answer, the endpoint answers 503 with `panelUnavailable: true` and
|
|
749
|
+
* carries NO decision and NO confidence, so `request` throws — deliberately, and
|
|
750
|
+
* that is the whole repair: the previous behaviour was a 200 carrying
|
|
751
|
+
* `decision: "NO", confidence: 65`, which every consumer rendered as a rejection
|
|
752
|
+
* of the submitted work. An outage is not a verdict, and a retry is the right
|
|
753
|
+
* next move rather than a resubmission with "stronger" evidence.
|
|
754
|
+
*
|
|
755
|
+
* A thrown 500 from the same endpoint is NOT this: it reports `false` here,
|
|
756
|
+
* because "the models did not answer" is a claim about the panel and a crash is
|
|
757
|
+
* not evidence for it.
|
|
758
|
+
*/
|
|
759
|
+
static isPanelUnavailable(err) {
|
|
760
|
+
const body = err?.body;
|
|
761
|
+
return typeof body === "object" && body !== null && body.panelUnavailable === true;
|
|
249
762
|
}
|
|
250
763
|
/**
|
|
251
764
|
* Submit a proof and wait for AI verification to complete — combines
|
|
252
765
|
* submitProof + polling into a single call. Times out after ~100s and
|
|
253
766
|
* returns { pending: true, proofId } so you can poll manually.
|
|
767
|
+
*
|
|
768
|
+
* Returns early, with `milestoneClosed: true`, when the milestone is already
|
|
769
|
+
* terminal — including when no verdict was recorded, which on a closed milestone
|
|
770
|
+
* will never change. Check `milestoneClosed` before `decision`: a `"YES"` on a
|
|
771
|
+
* closed, `EXPIRED` milestone is the panel's opinion of the proof and moved no funds.
|
|
772
|
+
*
|
|
773
|
+
* Also returns early, with `approvedWithoutThisVerdict: true`, when the milestone is
|
|
774
|
+
* VERIFIED or COMPLETED without a YES from this proof (another proof won it, or the
|
|
775
|
+
* funder approved it in review): `decision` is then `"NO"` or `null`, and the
|
|
776
|
+
* milestone's outcome is NOT this proof's.
|
|
777
|
+
*
|
|
778
|
+
* A proof parked for a human (no verdict, milestone at `PENDING_REVIEW`) returns
|
|
779
|
+
* `{ pending: true, proofId, awaitingHumanReview: true }` on the first poll instead of
|
|
780
|
+
* after the full timeout: no poll will change it, the funder decides.
|
|
781
|
+
*
|
|
782
|
+
* Neither early return is taken while the server reports `running: true` on a
|
|
783
|
+
* VERIFIED or PENDING_REVIEW milestone: this proof's panel is still running and can
|
|
784
|
+
* still decide the row, so the loop keeps polling. A server that predates `running`
|
|
785
|
+
* cannot say so, and there the early returns stay as described.
|
|
254
786
|
*/
|
|
255
787
|
async watchVerification(input) {
|
|
256
788
|
const { proofId } = await this.submitProof(input);
|
|
257
|
-
|
|
258
|
-
|
|
789
|
+
return this.waitForProof(proofId);
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Wait for the verification of a proof that is already submitted — the call to
|
|
793
|
+
* make after `submitProof`, and after `verify()` threw a 409 VERIFICATION_IN_PROGRESS
|
|
794
|
+
* with `phase: "running"` (T01-7). Same results as `watchVerification`, which is
|
|
795
|
+
* this plus the upload.
|
|
796
|
+
*
|
|
797
|
+
* Stops early, by throwing, when waiting cannot help: 401 / 403 (the key or the
|
|
798
|
+
* account does not change by waiting) and 404 (permanent — upload answers only
|
|
799
|
+
* after the proof exists and proof-status reads the primary database, so a 404
|
|
800
|
+
* means deleted or erased). Returns `{ pending: true, interrupted: true }` when the
|
|
801
|
+
* run that held the proof died (`claimPhase: "orphaned"`). On 429 / 503 it waits at
|
|
802
|
+
* least the server's Retry-After, never past `timeoutMs`; network errors and other
|
|
803
|
+
* 5xx are transient and it polls again.
|
|
804
|
+
*/
|
|
805
|
+
async waitForProof(proofId, options = {}) {
|
|
806
|
+
const MAX_WAIT_MS = options.timeoutMs ?? 100_000;
|
|
807
|
+
const POLL_MS = options.intervalMs ?? 5_000;
|
|
259
808
|
const started = Date.now();
|
|
809
|
+
let waitMs = POLL_MS;
|
|
260
810
|
while (Date.now() - started < MAX_WAIT_MS) {
|
|
261
|
-
await new Promise((r) => setTimeout(r,
|
|
262
|
-
|
|
263
|
-
|
|
811
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
812
|
+
waitMs = POLL_MS;
|
|
813
|
+
let status = null;
|
|
814
|
+
try {
|
|
815
|
+
status = await this.getProofStatus(proofId);
|
|
816
|
+
}
|
|
817
|
+
catch (e) {
|
|
818
|
+
const err = e;
|
|
819
|
+
if (err.status === 401 || err.status === 403 || err.status === 404)
|
|
820
|
+
throw e;
|
|
821
|
+
if ((err.status === 429 || err.status === 503) && typeof err.retryAfterSeconds === "number") {
|
|
822
|
+
waitMs = Math.min(Math.max(POLL_MS, err.retryAfterSeconds * 1000), Math.max(0, MAX_WAIT_MS - (Date.now() - started)));
|
|
823
|
+
}
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
// A closed milestone ends the wait whether or not a verdict landed: an outage
|
|
827
|
+
// that lost to EXPIRED leaves `pending: true` for good, and the loop used to burn
|
|
828
|
+
// its full timeout on it. `milestoneStatus` is the fallback for a server that
|
|
829
|
+
// predates `milestoneClosed`.
|
|
830
|
+
const closed = status ? closedMilestoneStatus(status) : null;
|
|
831
|
+
// Approved or paid without a YES from this proof: nothing is left for it to
|
|
832
|
+
// decide, whether or not it has a verdict of its own.
|
|
833
|
+
const elsewhere = status ? approvedWithoutThisVerdict(status) : false;
|
|
834
|
+
// No verdict can complete this milestone: final, whatever `pending` says.
|
|
835
|
+
const notCompletable = status?.notCompletableWithoutEscrow === true && closed === null;
|
|
836
|
+
if (status && (!status.pending || closed || elsewhere || notCompletable)) {
|
|
264
837
|
return {
|
|
265
838
|
pending: false,
|
|
266
|
-
|
|
839
|
+
// `?? "NO"` before: an ABSENT decision was reported as a rejection, the one
|
|
840
|
+
// direction that costs the builder their reputation. `null` says "the route
|
|
841
|
+
// reported none", which is the truth and forces the caller to look at
|
|
842
|
+
// `milestoneStatus`.
|
|
843
|
+
decision: status.decision ?? null,
|
|
267
844
|
confidence: status.confidence ?? null,
|
|
845
|
+
// Both forwarded, because `decision` answers a question about the PROOF and
|
|
846
|
+
// these answer the one about the MILESTONE. Dropping them is how a scheduled,
|
|
847
|
+
// contestable, unpaid approval reached a caller as a completed payout
|
|
848
|
+
// (SEC-058, at the fourth door of three that were fixed).
|
|
849
|
+
milestoneStatus: status.milestoneStatus,
|
|
850
|
+
notCompletableWithoutEscrow: notCompletable,
|
|
851
|
+
milestoneClosed: closed !== null,
|
|
852
|
+
terminalStatus: closed,
|
|
853
|
+
terminalReason: closed ? (status.terminalReason ?? null) : null,
|
|
854
|
+
approvedWithoutThisVerdict: elsewhere,
|
|
855
|
+
// MilestoneEscrowV3: the claim-relevant answer is the SUBMISSION's verdict, not
|
|
856
|
+
// this proof's `decision` (post-review G4) — forwarded, never derived here.
|
|
857
|
+
...(status.escrowVersion !== undefined ? { escrowVersion: status.escrowVersion } : {}),
|
|
858
|
+
...(status.escrowVersion === 3
|
|
859
|
+
? {
|
|
860
|
+
submissionId: status.submissionId ?? null,
|
|
861
|
+
manifestHash: status.manifestHash ?? null,
|
|
862
|
+
submissionVerdict: status.submissionVerdict ?? "PENDING",
|
|
863
|
+
claimReady: status.claimReady === true,
|
|
864
|
+
}
|
|
865
|
+
: {}),
|
|
268
866
|
reasoning: status.reasoning,
|
|
269
867
|
};
|
|
270
868
|
}
|
|
869
|
+
// Parked for a human: `pending` stays true on this path until the funder decides,
|
|
870
|
+
// so waiting out the loop only delayed the same answer by 100 s. Same test as
|
|
871
|
+
// `parkedForHuman` at both MCP surfaces.
|
|
872
|
+
if (status && parkedForHuman(status)) {
|
|
873
|
+
return { pending: true, proofId, awaitingHumanReview: true };
|
|
874
|
+
}
|
|
875
|
+
// The run that held this proof died: nothing is running to wait for. verify()
|
|
876
|
+
// can restart it once its claim is an hour old; submitting again always works.
|
|
877
|
+
if (status && status.claimPhase === "orphaned") {
|
|
878
|
+
return { pending: true, proofId, interrupted: true };
|
|
879
|
+
}
|
|
271
880
|
}
|
|
272
881
|
return { pending: true, proofId };
|
|
273
882
|
}
|
|
274
883
|
// ─── Agent marketplace ──────────────────────────────────────────────────────
|
|
275
|
-
/**
|
|
884
|
+
/**
|
|
885
|
+
* Look up any agent's on-chain reputation by EVM wallet address. No auth required.
|
|
886
|
+
*
|
|
887
|
+
* `summary` reaches you RAW, and so do `name` and `skills` from `discoverAgents`
|
|
888
|
+
* below. The route deliberately does not mark them (the browser profile page renders
|
|
889
|
+
* the same fields) and both MCP servers do mark them — so this SDK is a machine
|
|
890
|
+
* boundary that does not, exactly like `verify()`.
|
|
891
|
+
*
|
|
892
|
+
* The DECISION is the same and so is its reason: a library cannot know whether its
|
|
893
|
+
* return value ends in a model's context or on a page, where a marker is noise.
|
|
894
|
+
* What was missing here was the REASON, stated at `verify()` and nowhere
|
|
895
|
+
* else, which made this door look like an oversight rather than a choice (SEC-120).
|
|
896
|
+
* If you feed these fields to a model, treat them as data and not as instructions.
|
|
897
|
+
*/
|
|
276
898
|
async getReputation(walletAddress) {
|
|
277
899
|
return this.request("GET", `/api/agent/reputation/${encodeURIComponent(walletAddress)}`);
|
|
278
900
|
}
|