cascrow-mcp 1.7.0 → 3.0.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 +39 -0
- package/README.md +32 -29
- package/REMOTE_HTTP_SERVER.md +3 -3
- package/index.js +951 -226
- package/package.json +2 -2
- package/server.json +2 -2
- package/smithery.yaml +1 -1
package/index.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* Cascrow MCP Server
|
|
4
|
-
* Gives
|
|
5
|
-
* submit proofs,
|
|
4
|
+
* Gives an agent the ability to verify work with cascrow's AI panel, create
|
|
5
|
+
* contracts, submit proofs, read on-chain reputation and — for a milestone with
|
|
6
|
+
* an amount — take part in a non-custodial MilestoneEscrowV3 escrow (offer,
|
|
7
|
+
* acceptance, unsigned transactions). It never signs or sends a transaction:
|
|
8
|
+
* signing happens in the separate local signer, cascrow-signer.
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
11
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
@@ -28,6 +31,106 @@ function xrplTxUrl(hash) {
|
|
|
28
31
|
// IP-per-day tier as the web page). Every other tool needs a real account.
|
|
29
32
|
const ANONYMOUS_MODE = !API_KEY;
|
|
30
33
|
|
|
34
|
+
// Tools removed because they took a private key (2.0.0, cascrow Phase 5). The
|
|
35
|
+
// words are the server's own refusal (src/lib/custody-path-removed.ts), copied
|
|
36
|
+
// because a published package cannot import from the app;
|
|
37
|
+
// custody-removed-guards.test.ts holds the copy equal to the owner.
|
|
38
|
+
const REMOVED_CUSTODY_TOOLS = ["cascrow_escrow_fund"];
|
|
39
|
+
const CUSTODY_TOOL_REMOVED =
|
|
40
|
+
"Error: This way of funding has been removed: cascrow never accepts, creates, stores or signs with private keys. " +
|
|
41
|
+
"A $0 verification contract needs no funding — it is active once the builder has joined. " +
|
|
42
|
+
"A milestone with an amount is a MilestoneEscrowV3 escrow in native XRP, funded by the payer with one transaction " +
|
|
43
|
+
"from their own machine: first link the wallet you will fund from (GET /api/user/wallet/challenge, sign the message, " +
|
|
44
|
+
"POST /api/user/wallet), then run the local signer: npx cascrow-signer@0.1.0 fund --milestone <milestoneId>. " +
|
|
45
|
+
"If your request contained a private key, treat it as exposed and move its funds to a new wallet. " +
|
|
46
|
+
"[CUSTODY_PATH_REMOVED]";
|
|
47
|
+
|
|
48
|
+
// What every cascrow client says about MilestoneEscrowV3 — a JSON copy of
|
|
49
|
+
// V3_CLIENT_TEXT in src/lib/v3-client-text.ts (this package cannot import the
|
|
50
|
+
// app). v3-client-text-parity.test.ts holds the copy equal to the owner.
|
|
51
|
+
// BEGIN v3-client-text
|
|
52
|
+
const V3_CLIENT_TEXT = {
|
|
53
|
+
"signerPin": "cascrow-signer@0.1.0",
|
|
54
|
+
"remoteTools": [
|
|
55
|
+
"cascrow_v3_offer",
|
|
56
|
+
"cascrow_v3_acceptance_get",
|
|
57
|
+
"cascrow_v3_acceptance_submit",
|
|
58
|
+
"cascrow_v3_prepare",
|
|
59
|
+
"cascrow_v3_report_tx",
|
|
60
|
+
"cascrow_v3_attestation",
|
|
61
|
+
"cascrow_v3_claims"
|
|
62
|
+
],
|
|
63
|
+
"codeHints": {
|
|
64
|
+
"V3_DISABLED": "MilestoneEscrowV3 is switched off for NEW money right now (offers, acceptances, funding). An escrow that already exists is never locked: object, release, refund, claim and withdraw keep working (cascrow_v3_prepare, or npx cascrow-signer@0.1.0 without cascrow at all).",
|
|
65
|
+
"RATE_UNAVAILABLE": "cascrow could not get a consistent XRP/USD rate from its two sources, so no offer can be priced. Nothing was created; retry in a minute.",
|
|
66
|
+
"V3_NOT_OFFERED": "The payer has not offered MilestoneEscrowV3 terms for this milestone yet — only the payer can (cascrow_v3_offer). The payer has been notified.",
|
|
67
|
+
"OFFER_EXISTS": "An open offer already exists for this milestone and stays valid until it expires. Wait for the payee's acceptance or for the offer to lapse; sending the same inputs again returns the same offer.",
|
|
68
|
+
"V3_OFFER_OPEN": "An open MilestoneEscrowV3 offer, or a signed acceptance that is not funded yet, is bound to this milestone or wallet, so it cannot change now. Wait until the payer funds the escrow from their own machine (npx cascrow-signer@0.1.0 fund --milestone <milestoneId>) or the offer or acceptance lapses.",
|
|
69
|
+
"ATTESTATION_PENDING": "The AI panel has not finished on this claim. Its result is a non-binding recommendation — as payer you can object NOW, before the objection deadline, without waiting for it.",
|
|
70
|
+
"CLAIM_PROOF_UNKNOWN": "This claim's proofHash matches no evidence submitted to cascrow, so there is no AI recommendation for it. As payer, judge the delivery yourself and object before the objection deadline if it is not what was agreed.",
|
|
71
|
+
"V3_ESCROW": "This milestone is held in a MilestoneEscrowV3 escrow: only its payer, payee or arbiter moves that money, on chain, with their own key. Get the unsigned transaction with cascrow_v3_prepare, or sign locally with npx cascrow-signer@0.1.0.",
|
|
72
|
+
"V2_ESCROW_PRESENT": "This milestone or its contract still carries data of the retired v2 (RLUSD) escrow, so it cannot become a MilestoneEscrowV3 escrow. Nothing was changed; contact support.",
|
|
73
|
+
"INVALID_SIGNATURE": "The acceptance signature does not verify for your linked wallet over these exact terms (EIP-712, low-s, from an ordinary account). Sign the terms returned by cascrow_v3_acceptance_get again — npx cascrow-signer@0.1.0 accept does it and checks them first.",
|
|
74
|
+
"V3_INDEX_STALE": "cascrow's index of the chain is behind. Read the escrow from the chain directly (npx cascrow-signer@0.1.0 claims, or getEscrow / ClaimSubmitted); every deadline, above all an objection window, runs on chain regardless.",
|
|
75
|
+
"ACCEPTANCE_LAPSED": "The acceptance expired without funding, so the milestone is closed — this is not a failure of either party. A new attempt is a new milestone.",
|
|
76
|
+
"NOT_COMPLETABLE_WITHOUT_ESCROW": "The work was approved, but this milestone carries an amount without a MilestoneEscrowV3 escrow, and the previous escrow system has been removed — nothing can complete or pay it, so it was left as it was and NO money moved. cascrow has opened a case for it; contact support@cascrow.com with the contract ID. Do not resubmit: your evidence is recorded.",
|
|
77
|
+
"RELEASE_HOLD_REMOVED": "releaseHoldHours was removed with the previous escrow system; send the contract without it. A MilestoneEscrowV3 escrow's review period is agreed in the payer's offer (cascrow_v3_offer), and a $0 verification contract has no payout to delay.",
|
|
78
|
+
"AMOUNT_VIA_MILESTONES": "This draft has milestones, and their amounts are the contract's amount — it cannot be set on the contract. Nothing was changed. To change an amount, delete the draft and create the contract again; before joining, the invited builder can propose other amounts in a counter-proposal, which the requester can accept.",
|
|
79
|
+
"DEADLINE_VIA_MILESTONES": "This draft has milestones, and their deadlines are the ones enforced — a contract-level deadline changes nothing, so it is refused. Nothing was changed. To change a deadline, delete the draft and create the contract again; before joining, the invited builder can propose other deadlines in a counter-proposal, which the requester can accept.",
|
|
80
|
+
"MIXED_AMOUNTS": "A contract is either verification-only (every milestone $0) or escrowed (every milestone above $0), never both. Nothing was created or changed. Set every milestone to $0, or give every milestone an amount and fund each one through a MilestoneEscrowV3 offer (cascrow_v3_offer) once the builder has joined."
|
|
81
|
+
},
|
|
82
|
+
"joined": {
|
|
83
|
+
"active": "Joined as Builder. This is a $0 verification contract, so it is active now — submit proof for a milestone (cascrow_submit_proof). No money is held or moved.",
|
|
84
|
+
"awaitingOffer": "Joined as Builder. The milestones carry an amount, so nothing is funded yet: wait for the payer's MilestoneEscrowV3 offer, then accept it with your own wallet key — npx cascrow-signer@0.1.0 accept --milestone <milestoneId>, or on the contract page. cascrow never signs for you. Submit proof once the payer has funded the milestone.",
|
|
85
|
+
"unknown": "Joined as Builder. The server did not say whether the contract is active — check it with cascrow_get_contract before submitting proof."
|
|
86
|
+
},
|
|
87
|
+
"payeeHints": {
|
|
88
|
+
"CLAIM_PROOF_UNKNOWN": "This manifest is not a sealed submission of this milestone, so cascrow will not confirm it and nothing was signed. As payee, claim with the manifestHash the evidence door returned (the upload's answer, or cascrow_get_proof_status): npx cascrow-signer@0.1.0 submit-claim --milestone <milestoneId> --proof-hash <manifestHash>."
|
|
89
|
+
},
|
|
90
|
+
"fundRemoved": "cascrow_fund_milestone was removed (and with it fundMilestone in cascrow-sdk and `cascrow fund`). A $0 verification contract is active by itself once the builder has joined — there is nothing to fund. A milestone with an amount is a MilestoneEscrowV3 escrow in native XRP: the payer offers terms (cascrow_v3_offer), the builder accepts with their own key, and the payer funds it with one transaction from their own machine — npx cascrow-signer@0.1.0 fund --milestone <milestoneId>. cascrow never signs or sends for you.",
|
|
91
|
+
"recommendation": "🧭 AI RECOMMENDATION {verdict} for this MilestoneEscrowV3 submission ({confidence}% confidence on this file) — this verdict moves NO money. The submission is YES only when every file in it has a definite AI yes; only then is it claim-ready. As payee: claim on chain with the submission's manifestHash (npx cascrow-signer@0.1.0 submit-claim --milestone <milestoneId> --proof-hash <manifestHash>). As payer: release, or object before the objection deadline.",
|
|
92
|
+
"reportTxOnlyHash": "cascrow_v3_report_tx takes a transaction HASH (0x + 64 hex characters) and nothing else. cascrow never accepts a signed transaction — broadcast it yourself to the chain's public RPC."
|
|
93
|
+
};
|
|
94
|
+
// END v3-client-text
|
|
95
|
+
const TX_HASH_RE = /^0x[0-9a-fA-F]{64}$/;
|
|
96
|
+
function v3HintFor(code, body) {
|
|
97
|
+
if (!code) return null;
|
|
98
|
+
// Role-aware (post-review H9): CLAIM_PROOF_UNKNOWN without objectionDeadline comes from
|
|
99
|
+
// prepare?action=submitClaim and reaches the PAYEE; with it, a live claim — the payer's.
|
|
100
|
+
const b = body && typeof body === "object" ? body : null;
|
|
101
|
+
const liveClaim = !!b && typeof b.objectionDeadline === "string";
|
|
102
|
+
if (b && !liveClaim && V3_CLIENT_TEXT.payeeHints[code]) return V3_CLIENT_TEXT.payeeHints[code];
|
|
103
|
+
return V3_CLIENT_TEXT.codeHints[code] || null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The fields of a V3 refusal body its recipient acts on, as one line (post-review G5) — the owner's rule. */
|
|
107
|
+
function v3RefusalDetail(body) {
|
|
108
|
+
if (!body || typeof body !== "object") return "";
|
|
109
|
+
const out = [];
|
|
110
|
+
if (typeof body.remedy === "string" && body.remedy) out.push(body.remedy);
|
|
111
|
+
if (Array.isArray(body.allowed)) out.push(`Allowed now: ${body.allowed.length ? body.allowed.map(String).join(", ") : "nothing"}.`);
|
|
112
|
+
if (typeof body.objectionDeadline === "string" && body.objectionDeadline) out.push(`Objection deadline: ${body.objectionDeadline}.`);
|
|
113
|
+
if (typeof body.offerExpiresAt === "string" && body.offerExpiresAt) out.push(`Open offer expires: ${body.offerExpiresAt}.`);
|
|
114
|
+
return out.join(" ");
|
|
115
|
+
}
|
|
116
|
+
// `submissionVerdict` is the SUBMISSION's verdict (YES / NO / PENDING), never the
|
|
117
|
+
// panel's `decision` on one file — a file at YES 70 % is a NO for the claim. A body
|
|
118
|
+
// without it reads as PENDING. Mirrors v3RecommendationLine in src/lib/v3-client-text.ts.
|
|
119
|
+
/**
|
|
120
|
+
* The line for a `POST /api/contracts/join` answer — same rule as `joinedLine` in
|
|
121
|
+
* src/lib/v3-client-text.ts (v3-client-text-parity.test.ts runs this copy against it).
|
|
122
|
+
*/
|
|
123
|
+
function joinedLine(body) {
|
|
124
|
+
const activated = body && typeof body === "object" ? body.activated : undefined;
|
|
125
|
+
if (activated === true) return V3_CLIENT_TEXT.joined.active;
|
|
126
|
+
if (activated === false) return V3_CLIENT_TEXT.joined.awaitingOffer;
|
|
127
|
+
return V3_CLIENT_TEXT.joined.unknown;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function v3RecommendationLine(submissionVerdict, confidence) {
|
|
131
|
+
return V3_CLIENT_TEXT.recommendation.replace("{verdict}", submissionVerdict ?? "PENDING").replace("{confidence}", String(confidence ?? 0));
|
|
132
|
+
}
|
|
133
|
+
|
|
31
134
|
if (ANONYMOUS_MODE) {
|
|
32
135
|
process.stderr.write(
|
|
33
136
|
"NOTE: CASCROW_API_KEY is not set — running in anonymous mode. " +
|
|
@@ -43,47 +146,123 @@ if (ANONYMOUS_MODE) {
|
|
|
43
146
|
|
|
44
147
|
const authHeader = API_KEY ? { "Authorization": `Bearer ${API_KEY}` } : {};
|
|
45
148
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
149
|
+
// Client deadlines. This package cannot import src/, so the two that have an owner
|
|
150
|
+
// are MIRRORED here and checked against it by client-deadline-mirrors.test.ts:
|
|
151
|
+
// UPLOAD_CLIENT_ABORT_MS mirrors UPLOAD_CLIENT_ABORT_MS in src/lib/latency-budget.ts
|
|
152
|
+
// MCP_SUBMIT_CLIENT_ABORT_MS mirrors MCP_SUBMIT_CLIENT_ABORT_MS in src/lib/latency-budget.ts
|
|
153
|
+
// Every other call gets REQUEST_CLIENT_ABORT_MS — the longest maxDuration any route
|
|
154
|
+
// has, so it only ever stops a request the platform has already given up on. Before
|
|
155
|
+
// T11 these calls had no deadline at all: a hung connection hung the tool call.
|
|
156
|
+
const UPLOAD_CLIENT_ABORT_MS = 120_000;
|
|
157
|
+
const MCP_SUBMIT_CLIENT_ABORT_MS = 235_000;
|
|
158
|
+
const REQUEST_CLIENT_ABORT_MS = 300_000;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* An error from the API with what a caller needs to decide what to do next: the
|
|
162
|
+
* HTTP status, the stable `code` (src/lib/api-error.ts — or `UPSTREAM_NON_JSON` /
|
|
163
|
+
* `CLIENT_TIMEOUT`, which this client produces itself), and how long the server
|
|
164
|
+
* asked us to wait (`Retry-After`, else `retry_after_seconds`).
|
|
165
|
+
*/
|
|
166
|
+
class CascrowApiError extends Error {
|
|
167
|
+
constructor(message, { status, code = null, retryAfterSeconds = null, body = null } = {}) {
|
|
168
|
+
super(message);
|
|
169
|
+
this.name = "CascrowApiError";
|
|
170
|
+
this.status = status;
|
|
171
|
+
this.code = code;
|
|
172
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
173
|
+
this.body = body;
|
|
58
174
|
}
|
|
59
|
-
return data;
|
|
60
175
|
}
|
|
61
176
|
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
177
|
+
function retryAfterOf(res, data) {
|
|
178
|
+
const header = res.headers && typeof res.headers.get === "function" ? res.headers.get("retry-after") : null;
|
|
179
|
+
const fromHeader = header !== null && /^\d+$/.test(header.trim()) ? Number(header.trim()) : null;
|
|
180
|
+
if (fromHeader !== null) return fromHeader;
|
|
181
|
+
return typeof data?.retry_after_seconds === "number" ? data.retry_after_seconds : null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The one way this server talks to the API (T11-A3).
|
|
186
|
+
*
|
|
187
|
+
* The body is read as TEXT and parsed in a try: an HTML 502 from the platform used
|
|
188
|
+
* to reach `res.json()` and surface as "Unexpected token '<'", naming neither the
|
|
189
|
+
* status nor anything to do about it. The deadline covers the body read too — the
|
|
190
|
+
* timer is cleared after it, not after the headers (SEC-073's lesson). No retry
|
|
191
|
+
* here: a single call either answers or throws; only the poll loop waits and asks
|
|
192
|
+
* again.
|
|
193
|
+
*/
|
|
194
|
+
async function fetchJson(method, path, { json, form, deadlineMs = REQUEST_CLIENT_ABORT_MS } = {}) {
|
|
195
|
+
const controller = new AbortController();
|
|
196
|
+
const timer = setTimeout(() => controller.abort(), deadlineMs);
|
|
197
|
+
let res;
|
|
198
|
+
let text;
|
|
199
|
+
try {
|
|
200
|
+
res = await fetch(`${BASE_URL}${path}`, {
|
|
201
|
+
method,
|
|
202
|
+
headers: { ...authHeader, ...(json !== undefined ? { "Content-Type": "application/json" } : {}) },
|
|
203
|
+
...(json !== undefined ? { body: JSON.stringify(json) } : form !== undefined ? { body: form } : {}),
|
|
204
|
+
signal: controller.signal,
|
|
205
|
+
});
|
|
206
|
+
text = await res.text();
|
|
207
|
+
} catch (e) {
|
|
208
|
+
if (e && e.name === "AbortError") {
|
|
209
|
+
throw new CascrowApiError(
|
|
210
|
+
`No answer from ${path} within ${Math.round(deadlineMs / 1000)} s. The request may still complete on the server — check its result before repeating it.`,
|
|
211
|
+
{ status: 0, code: "CLIENT_TIMEOUT" }
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
throw e;
|
|
215
|
+
} finally {
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
}
|
|
218
|
+
let data;
|
|
219
|
+
try {
|
|
220
|
+
data = text ? JSON.parse(text) : {};
|
|
221
|
+
} catch {
|
|
222
|
+
data = { error: `HTTP ${res.status} (non-JSON response from server)`, code: "UPSTREAM_NON_JSON" };
|
|
223
|
+
// A 200 that is not JSON is not a success anyone can read.
|
|
224
|
+
if (res.ok) throw new CascrowApiError(data.error, { status: res.status, code: data.code });
|
|
225
|
+
}
|
|
67
226
|
if (!res.ok) {
|
|
68
|
-
|
|
227
|
+
const code = typeof data?.code === "string" ? data.code : null;
|
|
228
|
+
const retryAfterSeconds = retryAfterOf(res, data);
|
|
229
|
+
const base = typeof data?.error === "string" && data.error ? data.error : `HTTP ${res.status}`;
|
|
230
|
+
// A platform error page on a POST (typically the 504 when a route hits its own
|
|
231
|
+
// maxDuration — contracts/review settles on chain inside its own) does not mean the
|
|
232
|
+
// call failed: the function may have moved money before it was killed. Say so, and
|
|
233
|
+
// how to check.
|
|
234
|
+
const mayHaveCompleted = method !== "GET" && res.status >= 500 && data.code === "UPSTREAM_NON_JSON";
|
|
235
|
+
throw new CascrowApiError(
|
|
236
|
+
`${base}${code ? ` [${code}]` : ""}${retryAfterSeconds !== null && (res.status === 429 || res.status === 503) ? ` — retry after ${retryAfterSeconds} s` : ""}` +
|
|
237
|
+
(mayHaveCompleted ? ". The request may still have completed on the server — check the contract (cascrow_get_contract) before repeating it." : ""),
|
|
238
|
+
{ status: res.status, code, retryAfterSeconds, body: data }
|
|
239
|
+
);
|
|
69
240
|
}
|
|
70
241
|
return data;
|
|
71
242
|
}
|
|
72
243
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
});
|
|
82
|
-
const data = await res.json();
|
|
83
|
-
if (!res.ok) {
|
|
84
|
-
throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
244
|
+
/** A response body parsed without trusting it to be JSON (see fetchJson). */
|
|
245
|
+
async function readJsonSafe(res) {
|
|
246
|
+
const text = await res.text();
|
|
247
|
+
if (!text) return {};
|
|
248
|
+
try {
|
|
249
|
+
return JSON.parse(text);
|
|
250
|
+
} catch {
|
|
251
|
+
return { error: `HTTP ${res.status} (non-JSON response from server)`, code: "UPSTREAM_NON_JSON" };
|
|
85
252
|
}
|
|
86
|
-
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// The names the handlers (and the tests that extract them) use. Thin wrappers.
|
|
256
|
+
async function apiPost(path, body, deadlineMs) {
|
|
257
|
+
return fetchJson("POST", path, { json: body, deadlineMs });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function apiGet(path) {
|
|
261
|
+
return fetchJson("GET", path);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function apiPatch(path, body) {
|
|
265
|
+
return fetchJson("PATCH", path, { json: body });
|
|
87
266
|
}
|
|
88
267
|
|
|
89
268
|
async function uploadProof(milestoneId, filename, content) {
|
|
@@ -91,17 +270,7 @@ async function uploadProof(milestoneId, filename, content) {
|
|
|
91
270
|
const form = new FormData();
|
|
92
271
|
form.append("file", blob, filename);
|
|
93
272
|
form.append("milestoneId", milestoneId);
|
|
94
|
-
|
|
95
|
-
const res = await fetch(`${BASE_URL}/api/proof/upload`, {
|
|
96
|
-
method: "POST",
|
|
97
|
-
headers: { ...authHeader },
|
|
98
|
-
body: form,
|
|
99
|
-
});
|
|
100
|
-
const data = await res.json();
|
|
101
|
-
if (!res.ok) {
|
|
102
|
-
throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
103
|
-
}
|
|
104
|
-
return data;
|
|
273
|
+
return fetchJson("POST", "/api/proof/upload", { form, deadlineMs: UPLOAD_CLIENT_ABORT_MS });
|
|
105
274
|
}
|
|
106
275
|
|
|
107
276
|
// Stream verify with a 235s deadline that covers the WHOLE read, quorum retries
|
|
@@ -153,8 +322,24 @@ async function streamVerify(proofId) {
|
|
|
153
322
|
// long-lived MCP server that holds the event loop and later aborts a fetch
|
|
154
323
|
// that finished minutes ago.
|
|
155
324
|
clearTimeout(timeout);
|
|
156
|
-
const
|
|
157
|
-
|
|
325
|
+
const text = await res.text().catch(() => "");
|
|
326
|
+
let data;
|
|
327
|
+
try { data = text ? JSON.parse(text) : {}; } catch { data = { error: `HTTP ${res.status} (non-JSON response from server)`, code: "UPSTREAM_NON_JSON" }; }
|
|
328
|
+
// T01-5. Upload starts verification by itself, so a start that lost the claim to
|
|
329
|
+
// a RUNNING run is the normal case, not a failure: wait for that run instead.
|
|
330
|
+
// Never read this 409's Retry-After as a wait — it is the ~1 h takeover cutoff
|
|
331
|
+
// (SEC-093); `expected_done_in_seconds` is how long the run needs.
|
|
332
|
+
if (res.status === 409 && data.code === "VERIFICATION_IN_PROGRESS" && data.phase === "running") {
|
|
333
|
+
const budgetMs = Math.max(30_000, Math.min(235_000, ((data.expected_done_in_seconds ?? 200) + 30) * 1000));
|
|
334
|
+
const watch = await pollUntilSettled(proofId, budgetMs);
|
|
335
|
+
return { attached: true, watch: watch ?? stillRunningResult(proofId) };
|
|
336
|
+
}
|
|
337
|
+
throw new CascrowApiError(`${data.error ?? `HTTP ${res.status}`}${data.code ? ` [${data.code}]` : ""}`, {
|
|
338
|
+
status: res.status,
|
|
339
|
+
code: typeof data.code === "string" ? data.code : null,
|
|
340
|
+
retryAfterSeconds: retryAfterOf(res, data),
|
|
341
|
+
body: data,
|
|
342
|
+
});
|
|
158
343
|
}
|
|
159
344
|
if (!res.body) {
|
|
160
345
|
clearTimeout(timeout);
|
|
@@ -223,18 +408,90 @@ async function streamVerify(proofId) {
|
|
|
223
408
|
|
|
224
409
|
// ─── Tool definitions ─────────────────────────────────────────────────────────
|
|
225
410
|
|
|
411
|
+
// ─── The bar a milestone is judged against ────────────────────────────────────
|
|
412
|
+
// COPY of normalizeMilestoneChecklist / normalizeCriteria in src/lib/milestone-bar-input.ts
|
|
413
|
+
// (this package cannot import src/). milestone-bar-input-parity.test.ts runs this copy
|
|
414
|
+
// against the owner over the same case matrix — change both, or that test goes red.
|
|
415
|
+
const CHECKLIST_MAX_ITEMS = 20;
|
|
416
|
+
const CHECKLIST_TITLE_MAX = 200;
|
|
417
|
+
const CHECKLIST_ID_MAX = 100;
|
|
418
|
+
const CRITERIA_MAX = 4000;
|
|
419
|
+
const CHECKLIST_SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW"];
|
|
420
|
+
|
|
421
|
+
function normalizeMilestoneChecklist(items) {
|
|
422
|
+
if (items === undefined || items === null) return undefined;
|
|
423
|
+
if (!Array.isArray(items)) throw new Error("verificationChecklist: must be an array of { title, severity?, id? }");
|
|
424
|
+
if (items.length === 0) return undefined;
|
|
425
|
+
if (items.length > CHECKLIST_MAX_ITEMS) throw new Error(`verificationChecklist: at most ${CHECKLIST_MAX_ITEMS} items`);
|
|
426
|
+
items.forEach((raw, i) => {
|
|
427
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
428
|
+
throw new Error(`verificationChecklist[${i}]: must be an object with a title`);
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
const withId = items.filter((it) => it.id !== undefined).length;
|
|
432
|
+
if (withId !== 0 && withId !== items.length) {
|
|
433
|
+
throw new Error("verificationChecklist: either give every item an id or none (ids are then assigned 1..n)");
|
|
434
|
+
}
|
|
435
|
+
const seen = new Set();
|
|
436
|
+
return items.map((it, i) => {
|
|
437
|
+
const title = typeof it.title === "string" ? it.title.trim() : "";
|
|
438
|
+
if (title.length === 0) throw new Error(`verificationChecklist[${i}]: title is required`);
|
|
439
|
+
if (title.length > CHECKLIST_TITLE_MAX) throw new Error(`verificationChecklist[${i}]: title must be at most ${CHECKLIST_TITLE_MAX} characters`);
|
|
440
|
+
let id = i + 1;
|
|
441
|
+
if (withId) {
|
|
442
|
+
if (typeof it.id !== "number" || !Number.isInteger(it.id) || it.id < 1 || it.id > CHECKLIST_ID_MAX) {
|
|
443
|
+
throw new Error(`verificationChecklist[${i}]: id must be an integer from 1 to ${CHECKLIST_ID_MAX}`);
|
|
444
|
+
}
|
|
445
|
+
if (seen.has(it.id)) throw new Error(`verificationChecklist[${i}]: duplicate id ${it.id}`);
|
|
446
|
+
seen.add(it.id);
|
|
447
|
+
id = it.id;
|
|
448
|
+
}
|
|
449
|
+
const out = { id, title };
|
|
450
|
+
if (it.severity !== undefined && it.severity !== null && it.severity !== "") {
|
|
451
|
+
const sev = typeof it.severity === "string" ? it.severity.trim().toUpperCase() : "";
|
|
452
|
+
if (!CHECKLIST_SEVERITIES.includes(sev)) {
|
|
453
|
+
throw new Error(`verificationChecklist[${i}]: severity must be one of ${CHECKLIST_SEVERITIES.join(", ")}`);
|
|
454
|
+
}
|
|
455
|
+
out.severity = sev;
|
|
456
|
+
}
|
|
457
|
+
return out;
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function normalizeCriteria(value) {
|
|
462
|
+
if (value === undefined || value === null) return undefined;
|
|
463
|
+
if (typeof value !== "string") throw new Error("verificationCriteria: must be a string");
|
|
464
|
+
const v = value.trim();
|
|
465
|
+
if (v.length === 0) return undefined;
|
|
466
|
+
if (v.length > CRITERIA_MAX) throw new Error(`verificationCriteria: at most ${CRITERIA_MAX} characters (got ${v.length})`);
|
|
467
|
+
return v;
|
|
468
|
+
}
|
|
469
|
+
|
|
226
470
|
const TOOLS = [
|
|
227
471
|
{
|
|
228
472
|
name: "cascrow_create_contract",
|
|
229
473
|
description:
|
|
230
474
|
"Create a Cascrow verification contract with one or more milestones. " +
|
|
231
|
-
"Each milestone
|
|
232
|
-
"
|
|
475
|
+
"Each milestone has a short title, a deadline and — separately — the bar the AI panel judges it against: " +
|
|
476
|
+
"verificationCriteria (prose) and/or verificationChecklist (items). Put acceptance criteria THERE, not in the title: " +
|
|
477
|
+
"the title is printed on the public certificate. " +
|
|
478
|
+
"Returns contractId, an inviteCode and a link to view the contract. " +
|
|
233
479
|
"Use this BEFORE starting any work so the user can see the plan.",
|
|
234
480
|
inputSchema: {
|
|
235
481
|
type: "object",
|
|
236
482
|
required: ["milestones"],
|
|
237
483
|
properties: {
|
|
484
|
+
agentReviewMode: {
|
|
485
|
+
type: "string",
|
|
486
|
+
enum: ["AUTO", "MANUAL", "MANUAL_AUTO"],
|
|
487
|
+
description:
|
|
488
|
+
"What happens when the AI panel is unsure (confidence 60–85). AUTO (default) rejects; MANUAL waits for your decision " +
|
|
489
|
+
"via cascrow_review_contract, and completes the milestone automatically after 14 days without one; MANUAL_AUTO waits 48 h for your decision, then completes it. " +
|
|
490
|
+
"AUTO also completes a milestone after 48 h without your decision when it reaches your review anyway — a rejection overturned on appeal, or a panel outage after an earlier real verdict. " +
|
|
491
|
+
"On either 48-hour mode a milestone the panel never judged (every run timed out) is not completed; it waits for your decision. " +
|
|
492
|
+
"The window is counted from the email notice to you, never before the milestone was put to you (a panel verdict, an escalation after the builder used up their resubmissions, or a rejection overturned on appeal); without a sent notice it does not start. " +
|
|
493
|
+
"Contracts without an amount only, and nothing moves: a milestone completed this way reports decidedBy \"sla\".",
|
|
494
|
+
},
|
|
238
495
|
milestones: {
|
|
239
496
|
type: "array",
|
|
240
497
|
minItems: 1,
|
|
@@ -245,7 +502,30 @@ const TOOLS = [
|
|
|
245
502
|
properties: {
|
|
246
503
|
title: {
|
|
247
504
|
type: "string",
|
|
248
|
-
description: "
|
|
505
|
+
description: "Short milestone title, e.g. 'Landing page hero section'. Printed on the public certificate — put the acceptance criteria in verificationCriteria / verificationChecklist instead.",
|
|
506
|
+
},
|
|
507
|
+
verificationCriteria: {
|
|
508
|
+
type: "string",
|
|
509
|
+
maxLength: 4000,
|
|
510
|
+
description:
|
|
511
|
+
"What the AI panel checks the proof against, in prose, e.g. 'Responsive down to 320px, LCP under 2 s on mobile, CTA links to /signup'. " +
|
|
512
|
+
"The panel judges the proof against this text; without it, it judges against the title alone.",
|
|
513
|
+
},
|
|
514
|
+
verificationChecklist: {
|
|
515
|
+
type: "array",
|
|
516
|
+
maxItems: 20,
|
|
517
|
+
description:
|
|
518
|
+
"Optional checklist the panel scores item by item on TEXT proofs (a YES then needs every item addressed — stricter than criteria alone). " +
|
|
519
|
+
"Image proofs are judged against the criteria, not the checklist. Give every item an id (1–100, unique) or none (ids are assigned 1..n).",
|
|
520
|
+
items: {
|
|
521
|
+
type: "object",
|
|
522
|
+
required: ["title"],
|
|
523
|
+
properties: {
|
|
524
|
+
id: { type: "integer", minimum: 1, maximum: 100 },
|
|
525
|
+
title: { type: "string", maxLength: 200 },
|
|
526
|
+
severity: { type: "string", enum: ["CRITICAL", "HIGH", "MEDIUM", "LOW"] },
|
|
527
|
+
},
|
|
528
|
+
},
|
|
249
529
|
},
|
|
250
530
|
deadlineDays: {
|
|
251
531
|
type: "number",
|
|
@@ -253,7 +533,7 @@ const TOOLS = [
|
|
|
253
533
|
},
|
|
254
534
|
amountUSD: {
|
|
255
535
|
type: "number",
|
|
256
|
-
description: "Optional
|
|
536
|
+
description: "Optional. Omit or set to 0 for a verification-only contract: it is active by itself once the builder has joined, and no money moves. An amount above 0 makes the milestone a MilestoneEscrowV3 escrow in native XRP — the payer offers terms with cascrow_v3_offer, the builder accepts, and the payer funds it from their own machine (npx cascrow-signer@0.1.0 fund).",
|
|
257
537
|
},
|
|
258
538
|
},
|
|
259
539
|
},
|
|
@@ -261,41 +541,6 @@ const TOOLS = [
|
|
|
261
541
|
},
|
|
262
542
|
},
|
|
263
543
|
},
|
|
264
|
-
{
|
|
265
|
-
name: "cascrow_fund_milestone",
|
|
266
|
-
description:
|
|
267
|
-
"Fund (activate) a milestone so it can receive proof. " +
|
|
268
|
-
"Call this after creating the contract and before submitting proof for a milestone.",
|
|
269
|
-
inputSchema: {
|
|
270
|
-
type: "object",
|
|
271
|
-
required: ["contractId"],
|
|
272
|
-
properties: {
|
|
273
|
-
contractId: { type: "string", description: "The contract ID returned by cascrow_create_contract" },
|
|
274
|
-
milestoneId: { type: "string", description: "Optional specific milestone ID. If omitted, funds the first available milestone." },
|
|
275
|
-
},
|
|
276
|
-
},
|
|
277
|
-
},
|
|
278
|
-
{
|
|
279
|
-
name: "cascrow_escrow_fund",
|
|
280
|
-
description:
|
|
281
|
-
"Fund a milestone with REAL on-chain RLUSD escrow using the agent's own EVM private key. " +
|
|
282
|
-
"This does a real approve + fundMilestone transaction on the XRPL EVM Sidechain — " +
|
|
283
|
-
"funds are locked in the smart contract and released automatically after AI verification. " +
|
|
284
|
-
"Use this instead of cascrow_fund_milestone when you want real escrow (not just verification).",
|
|
285
|
-
inputSchema: {
|
|
286
|
-
type: "object",
|
|
287
|
-
// `amountUSD` was REQUIRED and is not read by the route — the escrowed
|
|
288
|
-
// amount is the milestone's own. See the same correction in
|
|
289
|
-
// src/app/api/mcp/route.ts.
|
|
290
|
-
required: ["contractId", "agentPrivateKey"],
|
|
291
|
-
properties: {
|
|
292
|
-
contractId: { type: "string", description: "The contract ID to fund" },
|
|
293
|
-
agentPrivateKey: { type: "string", description: "The agent's EVM private key (hex, 0x-prefixed). The agent's wallet must hold enough RLUSD." },
|
|
294
|
-
// Intentionally absent: the route ignores a body amountUSD.
|
|
295
|
-
milestoneId: { type: "string", description: "Optional specific milestone ID. If omitted, funds the first available milestone." },
|
|
296
|
-
},
|
|
297
|
-
},
|
|
298
|
-
},
|
|
299
544
|
{
|
|
300
545
|
name: "cascrow_submit_proof",
|
|
301
546
|
description:
|
|
@@ -303,8 +548,9 @@ const TOOLS = [
|
|
|
303
548
|
"Write a detailed, specific proof report describing exactly what was built, " +
|
|
304
549
|
"with verifiable evidence (URLs, metrics, specific implementation details). " +
|
|
305
550
|
"The more specific and verifiable the proof, the higher the AI confidence score. " +
|
|
306
|
-
"NOTE: After submitting, the backend
|
|
307
|
-
"
|
|
551
|
+
"NOTE: After submitting, the backend starts AI verification by itself. " +
|
|
552
|
+
"Wait for the verdict with cascrow_watch_verification { proofId } (or poll cascrow_get_proof_status). " +
|
|
553
|
+
"Do NOT call cascrow_verify for a proof you just submitted — its verification is already running.",
|
|
308
554
|
inputSchema: {
|
|
309
555
|
type: "object",
|
|
310
556
|
required: ["milestoneId", "proof"],
|
|
@@ -324,10 +570,11 @@ const TOOLS = [
|
|
|
324
570
|
{
|
|
325
571
|
name: "cascrow_verify",
|
|
326
572
|
description:
|
|
327
|
-
"
|
|
573
|
+
"Start (or re-run) AI verification for a proof and return the result. " +
|
|
328
574
|
"5 AI models vote in parallel (Claude, GPT-4o, Gemini, Mistral, Cerebras). " +
|
|
329
575
|
"Returns decision, confidence score, action (VERIFIED/PENDING_REVIEW/REJECTED), and per-model votes. " +
|
|
330
|
-
"
|
|
576
|
+
"Upload already starts verification by itself: for a proof you just submitted use cascrow_watch_verification { proofId } instead. " +
|
|
577
|
+
"If a verification of this proof is already running, this call waits for it rather than failing. " +
|
|
331
578
|
"Confidence > 85 + YES = VERIFIED. Confidence 60–85 = PENDING_REVIEW (manual review needed). Confidence < 60 = REJECTED.",
|
|
332
579
|
inputSchema: {
|
|
333
580
|
type: "object",
|
|
@@ -353,7 +600,9 @@ const TOOLS = [
|
|
|
353
600
|
description:
|
|
354
601
|
"Join an existing contract as the Builder (the party that delivers the work). " +
|
|
355
602
|
"Use this when another agent or user has created a contract and shared an invite code with you. " +
|
|
356
|
-
"
|
|
603
|
+
"The answer says whether the contract is active now (activated: true — a $0 verification contract; submit proof right away) " +
|
|
604
|
+
"or waits for the payer's MilestoneEscrowV3 offer (activated: false — accept it with your own wallet key: " +
|
|
605
|
+
"npx cascrow-signer@0.1.0 accept --milestone <id>, then submit proof once the payer has funded it).",
|
|
357
606
|
inputSchema: {
|
|
358
607
|
type: "object",
|
|
359
608
|
required: ["inviteCode"],
|
|
@@ -405,7 +654,8 @@ const TOOLS = [
|
|
|
405
654
|
name: "cascrow_get_reputation",
|
|
406
655
|
description:
|
|
407
656
|
"Look up the on-chain reputation of any agent by their EVM wallet address. " +
|
|
408
|
-
"Returns milestones completed, milestones failed, success rate and
|
|
657
|
+
"Returns milestones completed, milestones failed, success rate and the value released to them — " +
|
|
658
|
+
"totalReleasedUsd, plus totalXrpReleased for MilestoneEscrowV3 escrows (totalRlusdReleased is deprecated: the retired v2 escrow only) — " +
|
|
409
659
|
"counted over EVERY eligible milestone. " +
|
|
410
660
|
"The `milestones` array is different: it carries only the records the builder has " +
|
|
411
661
|
"published (per-milestone opt-in, default private), each with its XRPL tx hashes as " +
|
|
@@ -499,17 +749,109 @@ const TOOLS = [
|
|
|
499
749
|
description:
|
|
500
750
|
"As the FUNDER, list the contracts on which you still owe a manual verdict. " +
|
|
501
751
|
"This is the pull behind the manual_review.required webhook: a webhook you missed is a decision " +
|
|
502
|
-
"that never gets made
|
|
503
|
-
"
|
|
504
|
-
"Each entry carries reviewMilestoneId; pass it to cascrow_review_contract."
|
|
752
|
+
"that never gets made — cascrow completes an undecided milestone when its review window closes: 48 hours under review mode MANUAL_AUTO or AUTO, 14 days otherwise. " +
|
|
753
|
+
"These are verification-only ($0) contracts: that completion moves no money. Poll this whenever you have work in flight — after a restart above all. " +
|
|
754
|
+
"Each entry carries reviewMilestoneId; pass it to cascrow_review_contract. " +
|
|
755
|
+
"MilestoneEscrowV3 claims are NOT decided here: when v3ClaimsPending is above 0, list them with cascrow_v3_claims " +
|
|
756
|
+
"and object on chain before each objectionDeadline — cascrow cannot object for you.",
|
|
757
|
+
inputSchema: { type: "object", properties: {} },
|
|
758
|
+
},
|
|
759
|
+
{
|
|
760
|
+
name: "cascrow_v3_claims",
|
|
761
|
+
description:
|
|
762
|
+
"As the PAYER of a MilestoneEscrowV3 escrow, list the claims that release your escrow to the builder unless YOUR wallet " +
|
|
763
|
+
"calls object() on chain before objectionDeadline. This is the pull behind the claim.submitted webhook — a veto with a " +
|
|
764
|
+
"clock must never depend on one delivery. cascrow cannot object for you: it holds no key of yours and relays nothing. " +
|
|
765
|
+
"objectable=false means the window has closed. If the index is not current the call fails and says so; then read " +
|
|
766
|
+
"getEscrow / ClaimSubmitted from the chain directly.",
|
|
505
767
|
inputSchema: { type: "object", properties: {} },
|
|
506
768
|
},
|
|
769
|
+
{
|
|
770
|
+
name: "cascrow_v3_offer",
|
|
771
|
+
description:
|
|
772
|
+
"As the PAYER, offer MilestoneEscrowV3 terms for a milestone (native XRP, one escrow per milestone). cascrow prices it from two XRP/USD sources, freezes the terms " +
|
|
773
|
+
"and notifies the builder, who accepts with their own key. reviewPeriodSec is your objection window after a claim: 24 h only with watcherDeclared=true " +
|
|
774
|
+
"(you declare that you run a watcher, e.g. npx cascrow-signer@0.1.0 watch), otherwise at least 72 h; a first job with a new builder gets 7 days. " +
|
|
775
|
+
"An arbiter is optional — without one an objection costs nothing and the money returns to you after the deadline. Answers OFFER_EXISTS with the open offer's expiry.",
|
|
776
|
+
inputSchema: {
|
|
777
|
+
type: "object",
|
|
778
|
+
required: ["milestoneId", "reviewPeriodSec", "watcherDeclared"],
|
|
779
|
+
properties: {
|
|
780
|
+
milestoneId: { type: "string" },
|
|
781
|
+
reviewPeriodSec: { type: "integer", minimum: 3600, description: "Objection window after each claim, in seconds." },
|
|
782
|
+
watcherDeclared: { type: "boolean", description: "true only if you run a watcher that objects in time; allows a 24 h window." },
|
|
783
|
+
arbiter: { type: "string", description: "Optional arbiter address (0x…). Never cascrow, never a party." },
|
|
784
|
+
arbitrationPeriodSec: { type: "integer", description: "With an arbiter: how long they have to decide after an objection." },
|
|
785
|
+
},
|
|
786
|
+
},
|
|
787
|
+
},
|
|
788
|
+
{
|
|
789
|
+
name: "cascrow_v3_acceptance_get",
|
|
790
|
+
description:
|
|
791
|
+
"As the BUILDER (payee), read the payer's frozen MilestoneEscrowV3 offer: the EIP-712 typed data to sign, escrowId, payer, amount in wei, the rate snapshot and acceptBy. " +
|
|
792
|
+
"Check every term yourself before signing — your signature binds you until acceptBy and cascrow cannot revoke it. " +
|
|
793
|
+
"npx cascrow-signer@0.1.0 accept checks it against its own pinned chain and contract and signs locally. Answers V3_NOT_OFFERED while the payer has not offered.",
|
|
794
|
+
inputSchema: { type: "object", required: ["milestoneId"], properties: { milestoneId: { type: "string" } } },
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
name: "cascrow_v3_acceptance_submit",
|
|
798
|
+
description:
|
|
799
|
+
"As the BUILDER, hand cascrow your acceptance signature (EIP-712 over exactly the terms from cascrow_v3_acceptance_get, signed by your linked wallet). " +
|
|
800
|
+
"This is the only signature of yours cascrow ever receives besides the wallet-link one. The payer is then notified to fund before acceptBy.",
|
|
801
|
+
inputSchema: {
|
|
802
|
+
type: "object",
|
|
803
|
+
required: ["offerId", "signature"],
|
|
804
|
+
properties: { offerId: { type: "string" }, signature: { type: "string", description: "0x-prefixed 65-byte EIP-712 signature." } },
|
|
805
|
+
},
|
|
806
|
+
},
|
|
807
|
+
{
|
|
808
|
+
name: "cascrow_v3_prepare",
|
|
809
|
+
description:
|
|
810
|
+
"Get the UNSIGNED MilestoneEscrowV3 transaction for an action: { chainId, to, value, data, call: { fn, args }, escrowId, action, role, expect }. " +
|
|
811
|
+
"cascrow never signs, sends or relays it. Before signing, rebuild data from the contract ABI yourself and check to/chainId against your OWN configuration — " +
|
|
812
|
+
"npx cascrow-signer@0.1.0 does all of that and sends to the public RPC. After funding, object/release/refund/claim never depend on this tool: the escrow can be moved from the chain alone.",
|
|
813
|
+
inputSchema: {
|
|
814
|
+
type: "object",
|
|
815
|
+
required: ["milestoneId", "action"],
|
|
816
|
+
properties: {
|
|
817
|
+
milestoneId: { type: "string" },
|
|
818
|
+
action: { type: "string", enum: ["fund", "submitClaim", "claim", "release", "object", "refund", "extendDeadline", "returnFunds", "withdraw"] },
|
|
819
|
+
proofHash: { type: "string", description: "submitClaim: the manifest hash of your evidence (0x…)." },
|
|
820
|
+
reasonHash: { type: "string", description: "object: keccak256 of your objection reason (0x…)." },
|
|
821
|
+
newDeadline: { type: "string", description: "extendDeadline: unix seconds." },
|
|
822
|
+
},
|
|
823
|
+
},
|
|
824
|
+
},
|
|
825
|
+
{
|
|
826
|
+
name: "cascrow_v3_report_tx",
|
|
827
|
+
description:
|
|
828
|
+
"After YOU broadcast a MilestoneEscrowV3 transaction, tell cascrow its hash so the index re-reads the chain now instead of within five minutes. " +
|
|
829
|
+
"Takes a transaction HASH only — never a signed transaction; cascrow relays nothing. Reporting is optional: the index reads the chain anyway.",
|
|
830
|
+
inputSchema: {
|
|
831
|
+
type: "object",
|
|
832
|
+
required: ["milestoneId", "txHash"],
|
|
833
|
+
properties: { milestoneId: { type: "string" }, txHash: { type: "string", description: "0x + 64 hex characters." } },
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
{
|
|
837
|
+
name: "cascrow_v3_attestation",
|
|
838
|
+
description:
|
|
839
|
+
"Read cascrow's signed AI attestation for a MilestoneEscrowV3 claim — a non-binding RECOMMENDATION (verified, confidence, proofHash, reportHash), never a release. " +
|
|
840
|
+
"ATTESTATION_PENDING means the panel is still running: as payer you can object now. CLAIM_PROOF_UNKNOWN means the claim's proofHash matches no evidence cascrow saw.",
|
|
841
|
+
inputSchema: {
|
|
842
|
+
type: "object",
|
|
843
|
+
required: ["escrowId", "claimIndex"],
|
|
844
|
+
properties: { escrowId: { type: "string" }, claimIndex: { type: "integer", minimum: 1 } },
|
|
845
|
+
},
|
|
846
|
+
},
|
|
507
847
|
{
|
|
508
848
|
name: "cascrow_review_contract",
|
|
509
849
|
description:
|
|
510
850
|
"As the FUNDER, approve or reject a milestone the AI panel could not decide on its own " +
|
|
511
|
-
"(confidence 60–85 lands in PENDING_REVIEW). APPROVE
|
|
851
|
+
"(confidence 60–85 lands in PENDING_REVIEW). APPROVE completes the milestone; " +
|
|
512
852
|
"REJECT hands the milestone back so they can resubmit stronger evidence — it is not terminal. " +
|
|
853
|
+
"This decides verification-only ($0) contracts and moves no money: a MilestoneEscrowV3 escrow is released or objected to " +
|
|
854
|
+
"on chain by the payer's own key (cascrow_v3_prepare, or npx cascrow-signer@0.1.0). " +
|
|
513
855
|
"Call cascrow_review_queue first: it names the contractId and the milestoneId. " +
|
|
514
856
|
"milestoneId is required once more than one milestone of that contract is pending.",
|
|
515
857
|
inputSchema: {
|
|
@@ -517,7 +859,7 @@ const TOOLS = [
|
|
|
517
859
|
required: ["contractId", "decision"],
|
|
518
860
|
properties: {
|
|
519
861
|
contractId: { type: "string", description: "The contract holding the milestone under review." },
|
|
520
|
-
decision: { type: "string", enum: ["APPROVE", "REJECT"], description: "APPROVE
|
|
862
|
+
decision: { type: "string", enum: ["APPROVE", "REJECT"], description: "APPROVE completes the milestone; REJECT returns it to the builder." },
|
|
521
863
|
milestoneId: { type: "string", description: "Required when more than one milestone of the contract is pending review." },
|
|
522
864
|
},
|
|
523
865
|
},
|
|
@@ -543,7 +885,7 @@ const TOOLS = [
|
|
|
543
885
|
{
|
|
544
886
|
name: "cascrow_mcp_submit",
|
|
545
887
|
description:
|
|
546
|
-
"Submit text-based proof and get a verdict in the same call — the 5-model AI panel runs synchronously and takes up to ~100 seconds,
|
|
888
|
+
"Submit text-based proof and get a verdict in the same call — the 5-model AI panel runs synchronously and takes up to ~100 seconds, so allow at least 4 minutes (the MCP default request timeout of 60 s is too short); a 409 saying an earlier submission is still being processed is usually your own timed-out call — wait the seconds it names and retry, your evidence was kept; a 409 with code V3_ESCROW is final — the milestone is held in a MilestoneEscrowV3 escrow that only its payer, payee or arbiter moves, on chain — do not retry. " +
|
|
547
889
|
"On approval it automatically releases funds on-chain and mints an NFT certificate. " +
|
|
548
890
|
"Use this instead of cascrow_submit_proof + cascrow_verify when your proof is text/code (no file upload needed). " +
|
|
549
891
|
"Returns verdict ('approved' | 'rejected' | 'pending_review'), confidence score, and on-chain proof URL.",
|
|
@@ -636,17 +978,22 @@ const TOOLS = [
|
|
|
636
978
|
{
|
|
637
979
|
name: "cascrow_watch_verification",
|
|
638
980
|
description:
|
|
639
|
-
"
|
|
640
|
-
"
|
|
981
|
+
"Wait for AI verification to complete — either submit a proof and wait (milestoneId + content), " +
|
|
982
|
+
"or wait for a proof you already submitted (proofId). Give one mode, not both. " +
|
|
641
983
|
"Returns the final YES/NO decision, confidence score, and per-model votes once verification is done. " +
|
|
642
|
-
"
|
|
984
|
+
"Stops early when waiting cannot help (proof deleted, no access, run interrupted) and says why. " +
|
|
985
|
+
"Times out after ~100 seconds and returns the proofId so you can call it again or poll cascrow_get_proof_status.",
|
|
643
986
|
inputSchema: {
|
|
644
987
|
type: "object",
|
|
645
|
-
required: [
|
|
988
|
+
required: [],
|
|
646
989
|
properties: {
|
|
990
|
+
proofId: {
|
|
991
|
+
type: "string",
|
|
992
|
+
description: "Wait for this already-submitted proof (e.g. from cascrow_submit_proof). Omit milestoneId and content in this mode.",
|
|
993
|
+
},
|
|
647
994
|
milestoneId: {
|
|
648
995
|
type: "string",
|
|
649
|
-
description: "
|
|
996
|
+
description: "Submit mode: the milestone ID to submit proof for",
|
|
650
997
|
},
|
|
651
998
|
content: {
|
|
652
999
|
type: "string",
|
|
@@ -663,7 +1010,7 @@ const TOOLS = [
|
|
|
663
1010
|
|
|
664
1011
|
// ─── Tool handlers ────────────────────────────────────────────────────────────
|
|
665
1012
|
|
|
666
|
-
async function handleCreateContract({ milestones }) {
|
|
1013
|
+
async function handleCreateContract({ milestones, agentReviewMode }) {
|
|
667
1014
|
const deadline = (days) => {
|
|
668
1015
|
const d = new Date();
|
|
669
1016
|
d.setDate(d.getDate() + (days ?? 7));
|
|
@@ -671,11 +1018,26 @@ async function handleCreateContract({ milestones }) {
|
|
|
671
1018
|
};
|
|
672
1019
|
|
|
673
1020
|
const payload = {
|
|
674
|
-
milestones: milestones.map((m) =>
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
1021
|
+
milestones: milestones.map((m, i) => {
|
|
1022
|
+
// Normalised here so a bad item is refused with its milestone and item index,
|
|
1023
|
+
// not as a 400 on the whole contract. Copy of src/lib/milestone-bar-input.ts;
|
|
1024
|
+
// milestone-bar-input-parity.test.ts runs this copy against the owner.
|
|
1025
|
+
let criteria, checklist;
|
|
1026
|
+
try {
|
|
1027
|
+
criteria = normalizeCriteria(m.verificationCriteria);
|
|
1028
|
+
checklist = normalizeMilestoneChecklist(m.verificationChecklist);
|
|
1029
|
+
} catch (e) {
|
|
1030
|
+
throw new Error(`milestones[${i}]: ${e.message}`);
|
|
1031
|
+
}
|
|
1032
|
+
return {
|
|
1033
|
+
title: m.title,
|
|
1034
|
+
amountUSD: m.amountUSD ?? 0,
|
|
1035
|
+
cancelAfter: deadline(m.deadlineDays),
|
|
1036
|
+
...(criteria !== undefined ? { verificationCriteria: criteria } : {}),
|
|
1037
|
+
...(checklist !== undefined ? { verificationChecklist: checklist } : {}),
|
|
1038
|
+
};
|
|
1039
|
+
}),
|
|
1040
|
+
...(agentReviewMode !== undefined ? { agentReviewMode } : {}),
|
|
679
1041
|
};
|
|
680
1042
|
|
|
681
1043
|
const data = await apiPost("/api/contracts", payload);
|
|
@@ -694,31 +1056,6 @@ async function handleCreateContract({ milestones }) {
|
|
|
694
1056
|
};
|
|
695
1057
|
}
|
|
696
1058
|
|
|
697
|
-
async function handleFundMilestone({ contractId, milestoneId }) {
|
|
698
|
-
const body = milestoneId ? { contractId, milestoneId } : { contractId };
|
|
699
|
-
const data = await apiPost("/api/agent/fund-milestone", body);
|
|
700
|
-
return {
|
|
701
|
-
milestoneId: data.milestoneId,
|
|
702
|
-
contractId: data.contractId,
|
|
703
|
-
status: data.status,
|
|
704
|
-
message: `Milestone funded and active. Ready to receive proof.`,
|
|
705
|
-
};
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
async function handleEscrowFund({ contractId, agentPrivateKey, milestoneId }) {
|
|
709
|
-
// No amountUSD: the route funds the milestone's own figure and drops any body
|
|
710
|
-
// field silently. Forwarding it made the lie look answered.
|
|
711
|
-
const data = await apiPost("/api/agent/escrow-fund", { contractId, agentPrivateKey, milestoneId });
|
|
712
|
-
return {
|
|
713
|
-
milestoneId: data.milestoneId,
|
|
714
|
-
contractId: data.contractId,
|
|
715
|
-
txHash: data.txHash,
|
|
716
|
-
amountUSD: data.amountUSD,
|
|
717
|
-
status: data.status,
|
|
718
|
-
message: `✅ Real escrow funded on-chain. ${data.amountUSD} RLUSD locked in smart contract. TX: ${data.txHash}`,
|
|
719
|
-
};
|
|
720
|
-
}
|
|
721
|
-
|
|
722
1059
|
async function handleSubmitProof({ milestoneId, proof, filename }) {
|
|
723
1060
|
const data = await uploadProof(
|
|
724
1061
|
milestoneId,
|
|
@@ -727,12 +1064,17 @@ async function handleSubmitProof({ milestoneId, proof, filename }) {
|
|
|
727
1064
|
);
|
|
728
1065
|
return {
|
|
729
1066
|
proofId: data.proofId,
|
|
730
|
-
message:
|
|
1067
|
+
message:
|
|
1068
|
+
`Proof submitted (proofId: ${data.proofId}). AI verification has started automatically. ` +
|
|
1069
|
+
`Wait for the verdict with cascrow_watch_verification { proofId: "${data.proofId}" } — do not call cascrow_verify, it is already running.`,
|
|
731
1070
|
};
|
|
732
1071
|
}
|
|
733
1072
|
|
|
734
1073
|
async function handleVerify({ proofId }) {
|
|
735
1074
|
const result = await streamVerify(proofId);
|
|
1075
|
+
// The run was already going (usually the one upload started): its result, read
|
|
1076
|
+
// from proof-status through the same formatters as cascrow_watch_verification.
|
|
1077
|
+
if (result && result.attached) return result.watch;
|
|
736
1078
|
const decision = result.decision ?? "?";
|
|
737
1079
|
const confidence = result.confidence ?? 0;
|
|
738
1080
|
const action = result.action ?? "?";
|
|
@@ -769,8 +1111,10 @@ async function handleVerify({ proofId }) {
|
|
|
769
1111
|
// `passed: true` told an agent the escrow settled when it did not — the worst
|
|
770
1112
|
// direction of error for the one consumer with no in-app inbox.
|
|
771
1113
|
const complianceBlocked = result.complianceBlocked === true;
|
|
772
|
-
//
|
|
773
|
-
|
|
1114
|
+
// The panel approved a milestone that carries an amount without a MilestoneEscrowV3
|
|
1115
|
+
// escrow, so nothing completed it (Phase 6). Its own action, and a refusal: before it
|
|
1116
|
+
// existed this arrived as a bare VERIFIED and read as "the payout failed, retry".
|
|
1117
|
+
const notCompletable = action === "NOT_COMPLETABLE_WITHOUT_ESCROW";
|
|
774
1118
|
// A dead panel and a genuinely borderline verdict rendered identically, and an
|
|
775
1119
|
// agent's only sensible response to the second (improve the evidence, resubmit) is
|
|
776
1120
|
// wasted effort against the first.
|
|
@@ -808,23 +1152,34 @@ async function handleVerify({ proofId }) {
|
|
|
808
1152
|
: "";
|
|
809
1153
|
|
|
810
1154
|
const txHash = result.txHash ?? null;
|
|
1155
|
+
// MilestoneEscrowV3: the verdict is a RECOMMENDATION and moves nothing. Asked
|
|
1156
|
+
// first, so no arm below can read it as a payout or a rejection.
|
|
1157
|
+
const v3Recommendation = action === "V3_RECOMMENDATION";
|
|
811
1158
|
let statusLine;
|
|
812
|
-
if (
|
|
813
|
-
statusLine =
|
|
1159
|
+
if (v3Recommendation) {
|
|
1160
|
+
statusLine = v3RecommendationLine(result.submissionVerdict, confidence);
|
|
1161
|
+
} else if (superseded) {
|
|
1162
|
+
// `settledAs` names a terminal row (e.g. EXPIRED) the verdict lost to, not a
|
|
1163
|
+
// competing proof — "another proof won" would send the agent to resubmit.
|
|
1164
|
+
const settledAs = result.settledAs;
|
|
1165
|
+
statusLine = settledAs
|
|
1166
|
+
? `ℹ️ SUPERSEDED — this milestone was closed (${settledAs}) before this verification could settle it, so this verification changed nothing and moved no funds. Your evidence was recorded. Do not resubmit to this milestone.`
|
|
1167
|
+
: `ℹ️ SUPERSEDED — another proof on this milestone was decided first, so this verification did not change its state. Your evidence was recorded. Check the milestone status before resubmitting.`;
|
|
814
1168
|
} else if (released) {
|
|
815
1169
|
statusLine = alreadyReleased
|
|
816
|
-
? `✅ COMPLETED —
|
|
817
|
-
: `✅ COMPLETED (${confidence}% confidence) —
|
|
1170
|
+
? `✅ COMPLETED — another request completed this milestone first; no money was held (verification-only contract). Do not resubmit.`
|
|
1171
|
+
: `✅ COMPLETED (${confidence}% confidence) — no money was held (verification-only contract)${txHash ? `, tx ${txHash}` : ""}.`;
|
|
818
1172
|
} else if (complianceBlocked) {
|
|
819
1173
|
statusLine = `⏸️ VERIFIED (${confidence}%) — payout on hold pending compliance review. No funds moved.`;
|
|
820
|
-
} else if (
|
|
821
|
-
statusLine =
|
|
1174
|
+
} else if (notCompletable) {
|
|
1175
|
+
statusLine = `⛔ NOT COMPLETED (${confidence}% confidence) — ${V3_CLIENT_TEXT.codeHints.NOT_COMPLETABLE_WITHOUT_ESCROW}`;
|
|
822
1176
|
} else if (passed) {
|
|
823
1177
|
statusLine = `✅ VERIFIED (${confidence}% confidence)`;
|
|
824
1178
|
} else if (action === "VERIFIED") {
|
|
825
|
-
// Approved,
|
|
826
|
-
//
|
|
827
|
-
|
|
1179
|
+
// Approved, but no completion followed. Since Phase 6 every completion outcome
|
|
1180
|
+
// answers its own action, so the producer does not reach this today — it says so
|
|
1181
|
+
// rather than promising a retry that no longer exists.
|
|
1182
|
+
statusLine = `⚠️ VERIFIED (${confidence}% confidence) — the work was approved, but the milestone was NOT completed. No money moved. Check it with cascrow_get_contract; if it stays VERIFIED, contact support@cascrow.com.`;
|
|
828
1183
|
} else if (panelUnavailable) {
|
|
829
1184
|
statusLine = `⏳ PENDING_REVIEW — the verification panel did not answer (too few models responded). This is NOT a rejection and NOT a judgement of the work; a human decides. Resubmitting the same evidence will not help while the panel is down.`;
|
|
830
1185
|
} else if (pendingReview) {
|
|
@@ -844,6 +1199,11 @@ async function handleVerify({ proofId }) {
|
|
|
844
1199
|
decision,
|
|
845
1200
|
confidence,
|
|
846
1201
|
action,
|
|
1202
|
+
v3Recommendation,
|
|
1203
|
+
// MilestoneEscrowV3: the claim-relevant fields, structured as well as rendered.
|
|
1204
|
+
...(v3Recommendation
|
|
1205
|
+
? { manifestHash: result.manifestHash ?? null, submissionVerdict: result.submissionVerdict ?? "PENDING", claimReady: result.claimReady === true }
|
|
1206
|
+
: {}),
|
|
847
1207
|
passed,
|
|
848
1208
|
pendingReview,
|
|
849
1209
|
// Returned as well as rendered: a client that reads the structured fields rather
|
|
@@ -853,7 +1213,10 @@ async function handleVerify({ proofId }) {
|
|
|
853
1213
|
released,
|
|
854
1214
|
alreadyReleased,
|
|
855
1215
|
superseded,
|
|
856
|
-
|
|
1216
|
+
// Returned as well as rendered, as the hosted route does: with SUPERSEDED it says
|
|
1217
|
+
// the row was CLOSED rather than won by another proof.
|
|
1218
|
+
...(result.settledAs ? { settledAs: result.settledAs } : {}),
|
|
1219
|
+
notCompletableWithoutEscrow: notCompletable,
|
|
857
1220
|
reasoning,
|
|
858
1221
|
totalModels: TOTAL_MODELS,
|
|
859
1222
|
modelsResponded: votes.length,
|
|
@@ -871,9 +1234,14 @@ async function handleGetContract({ contractId }) {
|
|
|
871
1234
|
|
|
872
1235
|
async function handleJoinContract({ inviteCode }) {
|
|
873
1236
|
const data = await apiPost("/api/contracts/join", { inviteCode });
|
|
1237
|
+
// `activated` is the join's own activation result: a $0 contract is active now, a
|
|
1238
|
+
// money contract waits for its V3 offer. It said "AWAITING_ESCROW — ready to receive
|
|
1239
|
+
// proof once funded" for both, which sent a $0 builder waiting for money that never comes.
|
|
874
1240
|
return {
|
|
875
1241
|
contractId: data.contractId,
|
|
876
|
-
|
|
1242
|
+
status: data.status ?? null,
|
|
1243
|
+
activated: typeof data.activated === "boolean" ? data.activated : null,
|
|
1244
|
+
message: joinedLine(data),
|
|
877
1245
|
};
|
|
878
1246
|
}
|
|
879
1247
|
|
|
@@ -912,9 +1280,8 @@ async function handleCheckInvites() {
|
|
|
912
1280
|
// counterparty wrote about it: `name`, `skills` (PATCH /api/agent/settings,
|
|
913
1281
|
// 20 x 50 chars, no charset restriction) and a milestone `summary`. These tools
|
|
914
1282
|
// hand that straight into a DIFFERENT principal's model context while it is
|
|
915
|
-
// choosing who to hire
|
|
916
|
-
//
|
|
917
|
-
// text says.
|
|
1283
|
+
// choosing who to hire and about to fund them. Unmarked, cascrow is the
|
|
1284
|
+
// transport for whatever the text says.
|
|
918
1285
|
//
|
|
919
1286
|
// Why it is copied here rather than imported: this package ships to npm with
|
|
920
1287
|
// one dependency (@modelcontextprotocol/sdk) and cannot reach into the web
|
|
@@ -1068,7 +1435,7 @@ async function handleGetReputation({ walletAddress }) {
|
|
|
1068
1435
|
.filter(([, hash]) => hash)
|
|
1069
1436
|
.map(([k, hash]) => `${k}: ${xrplTxUrl(hash)}`)
|
|
1070
1437
|
.join(", ");
|
|
1071
|
-
return ` - ${m.summary ?? "Completed milestone"} ($${m.amountUSD}
|
|
1438
|
+
return ` - ${m.summary ?? "Completed milestone"} ($${m.amountUSD})${proofLinks ? ` — ${proofLinks}` : ""}`;
|
|
1072
1439
|
}).join("\n");
|
|
1073
1440
|
|
|
1074
1441
|
// An empty `milestones` array has two different causes and the agent has to be
|
|
@@ -1087,7 +1454,7 @@ async function handleGetReputation({ walletAddress }) {
|
|
|
1087
1454
|
message: [
|
|
1088
1455
|
`Agent reputation for ${walletAddress}:`,
|
|
1089
1456
|
` Completed: ${stats.milestonesCompleted} milestones`,
|
|
1090
|
-
|
|
1457
|
+
releasedLine(stats),
|
|
1091
1458
|
` Success rate: ${successPct}`,
|
|
1092
1459
|
skills.length > 0 ? ` Skills: ${skills.join(", ")}` : null,
|
|
1093
1460
|
milestones.length > 0 ? `\nPublished milestones:\n${milestoneLines}` : noPublishedNote,
|
|
@@ -1095,6 +1462,17 @@ async function handleGetReputation({ walletAddress }) {
|
|
|
1095
1462
|
};
|
|
1096
1463
|
}
|
|
1097
1464
|
|
|
1465
|
+
/**
|
|
1466
|
+
* The value released to the builder (owner decision 18, 2026-09-26): `totalReleasedUsd`
|
|
1467
|
+
* over every escrow version, the XRP a MilestoneEscrowV3 escrow paid next to it.
|
|
1468
|
+
* `totalRlusdReleased` is deprecated — the retired v2 escrow only — and is the
|
|
1469
|
+
* fallback for a server that predates the new field. Same line as the hosted server.
|
|
1470
|
+
*/
|
|
1471
|
+
function releasedLine(stats) {
|
|
1472
|
+
const xrp = stats.totalXrpReleased && Number(stats.totalXrpReleased) > 0 ? ` (${stats.totalXrpReleased} XRP from MilestoneEscrowV3 escrows)` : "";
|
|
1473
|
+
return ` Released to this builder: $${stats.totalReleasedUsd ?? stats.totalRlusdReleased}${xrp}`;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1098
1476
|
async function handleDiscoverAgents({ skill, minRate, limit } = {}) {
|
|
1099
1477
|
const params = new URLSearchParams();
|
|
1100
1478
|
if (skill) params.set("skill", skill);
|
|
@@ -1164,7 +1542,9 @@ async function handleSetDiscoverable({ discoverable, skills, callbackUrl } = {})
|
|
|
1164
1542
|
*
|
|
1165
1543
|
* The queue exists because a push without a pull is not a channel: an agent that
|
|
1166
1544
|
* was down when `manual_review.required` fired has no way back to the decision,
|
|
1167
|
-
* and `cron/cancel-expired`
|
|
1545
|
+
* and `cron/cancel-expired` completes it when its review window closes — 48 h
|
|
1546
|
+
* under MANUAL_AUTO/AUTO, 14 days otherwise (a verification-only contract: no
|
|
1547
|
+
* money moves, and nobody decided it — `decidedBy: "sla"`).
|
|
1168
1548
|
* The verdict ships in the same change rather than later, because a list its
|
|
1169
1549
|
* recipient cannot act on is the shape this project has already been burned by
|
|
1170
1550
|
* (F-72). This server and the hosted one at `src/app/api/mcp/route.ts` carry both
|
|
@@ -1173,9 +1553,12 @@ async function handleSetDiscoverable({ discoverable, skills, callbackUrl } = {})
|
|
|
1173
1553
|
async function handleReviewQueue() {
|
|
1174
1554
|
const data = await apiGet("/api/agent/review-queue");
|
|
1175
1555
|
const contracts = data.contracts ?? [];
|
|
1556
|
+
// The V3 hint is said in BOTH branches: an empty v2 queue is exactly when an
|
|
1557
|
+
// agent stops reading, and a claim window keeps running (post-review of 6faf3fdc).
|
|
1558
|
+
const v3 = v3ClaimsHint(data.v3ClaimsPending);
|
|
1176
1559
|
|
|
1177
1560
|
if (contracts.length === 0) {
|
|
1178
|
-
return { contracts: [], total: 0, truncated: false, message:
|
|
1561
|
+
return { ...data, contracts: [], total: 0, truncated: false, message: `No milestones are waiting for your verdict.${v3}` };
|
|
1179
1562
|
}
|
|
1180
1563
|
|
|
1181
1564
|
// The titles arrive already marked as counterparty-written by the route
|
|
@@ -1188,16 +1571,92 @@ async function handleReviewQueue() {
|
|
|
1188
1571
|
: pending.length > 1
|
|
1189
1572
|
? ` — ${pending.length} milestones pending; name one with milestoneId`
|
|
1190
1573
|
: " — contract-level review (omit milestoneId)";
|
|
1191
|
-
return ` - ${c.contractId}
|
|
1574
|
+
return ` - ${c.contractId} ${c.projectTitle}${ms}`;
|
|
1192
1575
|
}).join("\n");
|
|
1193
1576
|
|
|
1194
1577
|
const more = data.truncated ? `\n\n(${data.total} in total — this list is capped.)` : "";
|
|
1195
1578
|
return {
|
|
1196
1579
|
...data,
|
|
1197
|
-
message: `${data.total} contract(s) waiting for your verdict:\n${lines}${more}\n\nDecide with cascrow_review_contract
|
|
1580
|
+
message: `${data.total} contract(s) waiting for your verdict:\n${lines}${more}\n\nDecide with cascrow_review_contract.${v3}`,
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
function v3ClaimsHint(n) {
|
|
1585
|
+
if (n === null) return "\n\nOpen MilestoneEscrowV3 claims could not be counted — check cascrow_v3_claims.";
|
|
1586
|
+
if (typeof n === "number" && n > 0) return `\n\n${n} MilestoneEscrowV3 claim(s) can still be objected to — list them with cascrow_v3_claims.`;
|
|
1587
|
+
return "";
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
/**
|
|
1591
|
+
* The payer's open MilestoneEscrowV3 claims — `GET /api/agent/v3-claims`, the
|
|
1592
|
+
* same owner the review-queue count asks (design Rev. 8 final C). Pull only: the
|
|
1593
|
+
* action is the payer's own `object()` on chain (rule I9). The hosted twin is
|
|
1594
|
+
* `handleV3Claims` in `src/app/api/mcp/route.ts`.
|
|
1595
|
+
*/
|
|
1596
|
+
async function handleV3Claims() {
|
|
1597
|
+
const data = await apiGet("/api/agent/v3-claims");
|
|
1598
|
+
const claims = data.claims ?? [];
|
|
1599
|
+
if (claims.length === 0) return { ...data, claims: [], message: "No MilestoneEscrowV3 claim is waiting on you." };
|
|
1600
|
+
const lines = claims
|
|
1601
|
+
.map((c) => ` - escrow ${c.escrowId} on ${c.chainId}/${c.escrowContract} — ${c.milestoneTitle}, claim ${c.claimIndex}` +
|
|
1602
|
+
`${c.secondFunding ? " (SECOND funding)" : ""}${c.held ? " (held by the indexer — verify on chain)" : ""}: ` +
|
|
1603
|
+
(c.objectable === null ? "claim state unknown here — read getEscrow on chain" : c.objectable ? `object() before ${c.objectionDeadline}` : `window closed at ${c.objectionDeadline}`))
|
|
1604
|
+
.join("\n");
|
|
1605
|
+
const more = data.truncated ? "\n\n(This list is capped; more claims exist.)" : "";
|
|
1606
|
+
return { ...data, message: `${claims.length} claim(s):\n${lines}${more}\n\ncascrow cannot object for you — your own wallet calls object(escrowId) on the escrow contract.` };
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
// ── MilestoneEscrowV3, remote half (design Rev. 10). cascrow prepares and reads; the
|
|
1610
|
+
// party signs and sends with its own key — nothing here signs, sends or relays. The
|
|
1611
|
+
// hosted twins are the handleV3* functions in src/app/api/mcp/route.ts.
|
|
1612
|
+
async function handleV3Offer(args) {
|
|
1613
|
+
const data = await apiPost("/api/escrow/v3/offer", args);
|
|
1614
|
+
return { ...data, message: "Offer frozen and sent to the builder. They accept with their own key; you then fund with one transaction from your machine: npx cascrow-signer@0.1.0 fund --milestone " + args.milestoneId + "." };
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
async function handleV3AcceptanceGet({ milestoneId }) {
|
|
1618
|
+
const data = await apiGet(`/api/escrow/v3/acceptance?milestoneId=${encodeURIComponent(milestoneId)}`);
|
|
1619
|
+
return {
|
|
1620
|
+
...data,
|
|
1621
|
+
message:
|
|
1622
|
+
"These are the payer's frozen terms. Check them yourself — your signature binds you until acceptBy, and nothing in cascrow revokes it. " +
|
|
1623
|
+
"npx cascrow-signer@0.1.0 accept --milestone " + milestoneId + " checks them against its own pinned chain and contract and signs on your machine.",
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
async function handleV3AcceptanceSubmit({ offerId, signature }) {
|
|
1628
|
+
const data = await apiPost("/api/escrow/v3/acceptance", { offerId, signature });
|
|
1629
|
+
return { ...data, message: "Acceptance recorded. The payer can now fund these exact terms until acceptBy." };
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
async function handleV3Prepare({ milestoneId, action, proofHash, reasonHash, newDeadline }) {
|
|
1633
|
+
const q = new URLSearchParams({ milestoneId, action });
|
|
1634
|
+
if (proofHash) q.set("proofHash", proofHash);
|
|
1635
|
+
if (reasonHash) q.set("reasonHash", reasonHash);
|
|
1636
|
+
if (newDeadline) q.set("newDeadline", String(newDeadline));
|
|
1637
|
+
const data = await apiGet(`/api/escrow/v3/prepare?${q.toString()}`);
|
|
1638
|
+
return {
|
|
1639
|
+
...data,
|
|
1640
|
+
message:
|
|
1641
|
+
"UNSIGNED transaction — cascrow does not sign, send or relay it. Before signing: rebuild data from the contract ABI, and check to and chainId against your OWN configuration, not this answer. " +
|
|
1642
|
+
"Then broadcast it yourself and report the hash with cascrow_v3_report_tx. npx cascrow-signer@0.1.0 does all of this locally.",
|
|
1198
1643
|
};
|
|
1199
1644
|
}
|
|
1200
1645
|
|
|
1646
|
+
async function handleV3ReportTx({ milestoneId, txHash }) {
|
|
1647
|
+
// Refused BEFORE anything is sent: a signed transaction pasted here would be
|
|
1648
|
+
// cascrow receiving a transaction to relay, which it never does (rule I9).
|
|
1649
|
+
if (typeof txHash !== "string" || !TX_HASH_RE.test(txHash)) throw new CascrowApiError(V3_CLIENT_TEXT.reportTxOnlyHash, { status: 0, code: "NOT_A_TX_HASH" });
|
|
1650
|
+
const data = await apiPost("/api/escrow/v3/sync", { milestoneId, txHash });
|
|
1651
|
+
return { ...data, message: "Reported. cascrow re-reads the chain for this milestone; the transaction itself is only what the chain says it is." };
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
async function handleV3Attestation({ escrowId, claimIndex }) {
|
|
1655
|
+
const q = new URLSearchParams({ escrowId, claimIndex: String(claimIndex) });
|
|
1656
|
+
const data = await apiGet(`/api/escrow/v3/attestation?${q.toString()}`);
|
|
1657
|
+
return { ...data, message: "A non-binding AI recommendation, signed by cascrow. It moves no money: only the payer's release, an unobjected claim running out its window, or the arbiter pays." };
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1201
1660
|
async function handleReviewContract({ contractId, decision, milestoneId }) {
|
|
1202
1661
|
const data = await apiPost(
|
|
1203
1662
|
"/api/contracts/review",
|
|
@@ -1205,10 +1664,9 @@ async function handleReviewContract({ contractId, decision, milestoneId }) {
|
|
|
1205
1664
|
);
|
|
1206
1665
|
const what =
|
|
1207
1666
|
decision === "APPROVE"
|
|
1208
|
-
? "approved
|
|
1667
|
+
? "approved and completed (a verification-only contract: no money moves)"
|
|
1209
1668
|
: "rejected — the milestone is back with the builder, who can resubmit";
|
|
1210
|
-
|
|
1211
|
-
return { ...data, message: `Milestone ${what}.${tx}` };
|
|
1669
|
+
return { ...data, message: `Milestone ${what}.` };
|
|
1212
1670
|
}
|
|
1213
1671
|
|
|
1214
1672
|
async function handleListMyContracts({ role } = {}) {
|
|
@@ -1250,22 +1708,105 @@ async function handleListMyContracts({ role } = {}) {
|
|
|
1250
1708
|
*
|
|
1251
1709
|
* `decision` answers a question about the PROOF. What an agent needs is the state of
|
|
1252
1710
|
* the MILESTONE, and the two come apart three ways that all used to read as plain
|
|
1253
|
-
* "APPROVED": the
|
|
1254
|
-
*
|
|
1255
|
-
*
|
|
1711
|
+
* "APPROVED": the milestone is already done (COMPLETED), it carries an amount nothing can
|
|
1712
|
+
* complete (`notCompletableWithoutEscrow`), or this proof lost to another one and the
|
|
1713
|
+
* milestone is in someone else's terminal state.
|
|
1256
1714
|
*/
|
|
1257
1715
|
function proofOutcomeLine(s) {
|
|
1716
|
+
// FIRST, before `decision` is read at all: a milestone that is already terminal
|
|
1717
|
+
// cannot be moved by this proof's verdict. Since 2026-09-23 `/api/verify` leaves a
|
|
1718
|
+
// row that went EXPIRED mid-panel EXPIRED and still persists the verdict, so a YES
|
|
1719
|
+
// here used to print "✅ APPROVED" for money that may be back with the funder.
|
|
1720
|
+
const closed = closedMilestoneStatus(s);
|
|
1721
|
+
if (closed && closed !== "COMPLETED") return closedOutcomeLine(s, closed);
|
|
1722
|
+
// Before any verdict is read: an approval cannot complete this milestone (it carries
|
|
1723
|
+
// an amount without a MilestoneEscrowV3 escrow). A YES here is not "done" — the
|
|
1724
|
+
// verdict doors answered 409 NOT_COMPLETABLE_WITHOUT_ESCROW and a case is open.
|
|
1725
|
+
if (s.notCompletableWithoutEscrow === true) return `⛔ NOT COMPLETABLE — ${V3_CLIENT_TEXT.codeHints.NOT_COMPLETABLE_WITHOUT_ESCROW}`;
|
|
1726
|
+
// SECOND: the milestone is approved (VERIFIED) or paid (COMPLETED), and not on a YES
|
|
1727
|
+
// from this proof. Its `decision` is then NO or absent, and printing it next to
|
|
1728
|
+
// "COMPLETED — funds released" or "APPROVED — payout scheduled" read as THIS proof
|
|
1729
|
+
// having been approved.
|
|
1730
|
+
if (approvedWithoutThisVerdict(s)) return approvedWithoutThisVerdictLine(s);
|
|
1731
|
+
// MilestoneEscrowV3, not yet paid: a RECOMMENDATION that moves nothing, judged by
|
|
1732
|
+
// the SUBMISSION's verdict — never "✅ APPROVED" off this file's `decision`.
|
|
1733
|
+
if (s.escrowVersion === 3 && s.milestoneStatus !== "COMPLETED") return v3RecommendationLine(s.submissionVerdict, s.confidence);
|
|
1258
1734
|
const head =
|
|
1259
|
-
s.milestoneStatus === "COMPLETED"
|
|
1260
|
-
?
|
|
1261
|
-
: s.
|
|
1262
|
-
? `⏸️ APPROVED — payout scheduled for ${s.releaseHoldUntil}, no funds have moved yet`
|
|
1263
|
-
: s.decision === "YES"
|
|
1735
|
+
s.milestoneStatus === "COMPLETED" || closed === "COMPLETED"
|
|
1736
|
+
? `✅ COMPLETED — ${s.escrowVersion === 3 ? "paid out on chain from its MilestoneEscrowV3 escrow" : "no money was held (verification-only contract)"}`
|
|
1737
|
+
: s.decision === "YES"
|
|
1264
1738
|
? "✅ APPROVED"
|
|
1265
1739
|
: "❌ REJECTED";
|
|
1266
1740
|
return `${head}${s.confidence != null ? ` (confidence: ${s.confidence}%)` : ""}`;
|
|
1267
1741
|
}
|
|
1268
1742
|
|
|
1743
|
+
/**
|
|
1744
|
+
* The terminal status of the row this proof settles against, or null while it is open.
|
|
1745
|
+
*
|
|
1746
|
+
* `milestoneClosed` is what `/api/agent/proof-status` derives from the row (the
|
|
1747
|
+
* milestone, or the contract on the legacy contract-level path). `milestoneStatus` is
|
|
1748
|
+
* the fallback for a server that predates the field — the same two statuses, so an
|
|
1749
|
+
* older deployment does not reopen the defect this exists for.
|
|
1750
|
+
*/
|
|
1751
|
+
function closedMilestoneStatus(s) {
|
|
1752
|
+
if (s.milestoneClosed === true) return s.terminalStatus || s.milestoneStatus || s.contractStatus || "EXPIRED";
|
|
1753
|
+
// A legacy contract-level proof has no milestone: an older server reports its row only
|
|
1754
|
+
// as `contractStatus`, and reading `milestoneStatus` alone printed APPROVED for a
|
|
1755
|
+
// proof whose contract had EXPIRED.
|
|
1756
|
+
const row = s.milestoneStatus ?? s.contractStatus;
|
|
1757
|
+
return row === "EXPIRED" || row === "COMPLETED" ? row : null;
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
/**
|
|
1761
|
+
* What happened to the money, from `terminalReason`. EXPIRED is not one story:
|
|
1762
|
+
* `RELEASED_ON_CHAIN` means the chain shows the builder WAS paid (just not by this
|
|
1763
|
+
* verification); the others mean the funder got the escrow back, or none existed. The
|
|
1764
|
+
* vocabulary is `src/lib/terminal-reason.ts`; an absent or unknown value says so
|
|
1765
|
+
* rather than guessing.
|
|
1766
|
+
*/
|
|
1767
|
+
function terminalStory(reason, row) {
|
|
1768
|
+
switch (reason) {
|
|
1769
|
+
case "RELEASED_ON_CHAIN":
|
|
1770
|
+
return "The chain shows this milestone's escrow WAS released to the builder before it was closed — the builder was paid, but not by this verification.";
|
|
1771
|
+
case "CANCELLED_AFTER_APPROVAL":
|
|
1772
|
+
return "An earlier approval lost the race to the funder's on-chain cancel; the escrow was refunded to the funder.";
|
|
1773
|
+
case "DEADLINE_EXPIRED":
|
|
1774
|
+
return "The deadline passed before the milestone was completed; any escrow it held went back to the funder (refunding the funder is the funder's own on-chain call for a MilestoneEscrowV3 escrow).";
|
|
1775
|
+
case "RENEGOTIATION_REJECTED":
|
|
1776
|
+
return "A requested extension was rejected and the milestone was closed; any escrow it held went back to the funder.";
|
|
1777
|
+
case "NO_FUNDED_ESCROW":
|
|
1778
|
+
return "No funded escrow existed on-chain for this milestone.";
|
|
1779
|
+
case "CONTRACT_DECLINED":
|
|
1780
|
+
return "The contract was declined or expired before anything was escrowed.";
|
|
1781
|
+
case "ARBITER_REFUNDED":
|
|
1782
|
+
return "The named arbiter decided for the funder, and the escrow was refunded to the funder.";
|
|
1783
|
+
case "REFUNDED_AFTER_OBJECTION_AI_NO":
|
|
1784
|
+
return "The funder objected to the builder's claim, the panel had not approved it, and the escrow was refunded to the funder.";
|
|
1785
|
+
case "REFUNDED_AFTER_OBJECTION_DISPUTED":
|
|
1786
|
+
return "The funder objected to a claim the panel had approved, and the escrow was refunded to the funder — recorded as a dispute, not as the builder's failure.";
|
|
1787
|
+
case "RETURNED_BY_PAYEE":
|
|
1788
|
+
return "The builder returned the escrow to the funder.";
|
|
1789
|
+
case "RETURNED_BEFORE_WORK":
|
|
1790
|
+
return "The builder returned the escrow to the funder before any work was claimed.";
|
|
1791
|
+
case "ACCEPTANCE_LAPSED":
|
|
1792
|
+
return "The builder accepted the escrow terms, but the funder did not fund them before the acceptance expired; the milestone ended with no money escrowed, and it does not count against the builder.";
|
|
1793
|
+
default:
|
|
1794
|
+
return `The reason was not recorded — check the ${row} before assuming where the funds are.`;
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
/** The outcome line for a proof whose milestone is terminal and not COMPLETED. */
|
|
1799
|
+
function closedOutcomeLine(s, closed) {
|
|
1800
|
+
const row = proofRowNoun(s);
|
|
1801
|
+
const verdict = s.decision
|
|
1802
|
+
? `The panel's verdict on this proof was ${s.decision}${s.confidence != null ? ` (confidence: ${s.confidence}%)` : ""}, and it changed nothing.`
|
|
1803
|
+
: "No verdict was recorded for this proof.";
|
|
1804
|
+
return (
|
|
1805
|
+
`⛔ CLOSED — the ${row} was closed (${closed}) while the panel ran or after it decided, so this verification moved no funds. ` +
|
|
1806
|
+
`${terminalStory(s.terminalReason, row)} ${verdict} There is nothing to poll for and nothing to resubmit to this ${row}.`
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1269
1810
|
|
|
1270
1811
|
/**
|
|
1271
1812
|
* One `verify/standalone` call under a client deadline (SEC-073).
|
|
@@ -1304,13 +1845,75 @@ async function withStandaloneDeadline(run) {
|
|
|
1304
1845
|
}
|
|
1305
1846
|
|
|
1306
1847
|
/**
|
|
1307
|
-
*
|
|
1308
|
-
*
|
|
1309
|
-
*
|
|
1310
|
-
|
|
1848
|
+
* "milestone", or "contract" for a legacy contract-level proof — which has no milestone,
|
|
1849
|
+
* so a message telling the agent to "check the milestone" sent it looking for nothing.
|
|
1850
|
+
* `milestoneStatus` is null exactly when the proof has no milestone row.
|
|
1851
|
+
*/
|
|
1852
|
+
function proofRowNoun(s) {
|
|
1853
|
+
return s.milestoneStatus == null ? "contract" : "milestone";
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
/**
|
|
1857
|
+
* True when the milestone is approved (VERIFIED) or paid (COMPLETED) and this proof has
|
|
1858
|
+
* no YES of its own: another proof won it, or the funder approved it in review — which
|
|
1859
|
+
* leaves this proof's `aiDecision` untouched (NO, or null after a panel outage).
|
|
1860
|
+
*
|
|
1861
|
+
* Either way nothing is left for THIS proof to decide. `/api/verify`'s claim excludes
|
|
1862
|
+
* VERIFIED and COMPLETED for every verdict, so a pending proof here can never move the
|
|
1863
|
+
* row: polling it burned the whole watch timeout, and `get_proof_status` said "poll
|
|
1864
|
+
* again" for good. `/api/verify`'s SSE answered SUPERSEDED for the same state.
|
|
1865
|
+
*/
|
|
1866
|
+
function approvedWithoutThisVerdict(s) {
|
|
1867
|
+
// Except while this proof's panel is RUNNING on a VERIFIED row. VERIFIED is not sticky:
|
|
1868
|
+
// a definite release failure rolls it back, `contracts/review` hands it back to
|
|
1869
|
+
// PENDING_REVIEW and the cron's hand-back blocks do the same, and a running claim then
|
|
1870
|
+
// decides the row after all. COMPLETED is terminal and stays final either way.
|
|
1871
|
+
// `running` is absent on a server that predates it, which keeps the old reading.
|
|
1872
|
+
const row = closedMilestoneStatus(s) === "COMPLETED" ? "COMPLETED" : (s.milestoneStatus ?? s.contractStatus);
|
|
1873
|
+
if (s.decision === "YES") return false;
|
|
1874
|
+
return row === "COMPLETED" || (row === "VERIFIED" && s.running !== true);
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
/** Final: no later poll can change what this proof status says. */
|
|
1878
|
+
function nothingLeftToDecide(s) {
|
|
1879
|
+
return closedMilestoneStatus(s) !== null || s.notCompletableWithoutEscrow === true || approvedWithoutThisVerdict(s);
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
/** The outcome line for `approvedWithoutThisVerdict`. It never says THIS proof was approved. */
|
|
1883
|
+
function approvedWithoutThisVerdictLine(s) {
|
|
1884
|
+
const row = proofRowNoun(s);
|
|
1885
|
+
const ROW = row.toUpperCase();
|
|
1886
|
+
const head =
|
|
1887
|
+
closedMilestoneStatus(s) === "COMPLETED"
|
|
1888
|
+
? `✅ ${ROW} COMPLETED — ${s.escrowVersion === 3 ? "paid out on chain from its MilestoneEscrowV3 escrow" : "no money was held (verification-only contract)"}`
|
|
1889
|
+
: `✅ ${ROW} VERIFIED — approved, not completed yet`;
|
|
1890
|
+
const verdict =
|
|
1891
|
+
s.decision === "NO"
|
|
1892
|
+
? `The panel did NOT approve this proof (NO${s.confidence != null ? `, confidence: ${s.confidence}%` : ""}).`
|
|
1893
|
+
: "This proof has no panel verdict of its own.";
|
|
1894
|
+
return (
|
|
1895
|
+
`${head}, but not on this proof's verdict: another proof on this ${row}, or the funder's review, approved it. ` +
|
|
1896
|
+
`${verdict} Nothing is left for this proof to decide — do not poll it and do not resubmit to this ${row}.`
|
|
1897
|
+
);
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
/**
|
|
1901
|
+
* `pending` means "no verdict on this proof": `aiDecision` is null (not claimed yet, or
|
|
1902
|
+
* the panel never reached a verdict and the proof was parked for human review with
|
|
1903
|
+
* `aiDecision` deliberately left null) or the claim sentinel "PENDING" (`/api/verify`'s
|
|
1904
|
+
* panel is running on it). Those are NOT the same thing: told to keep polling, an agent
|
|
1905
|
+
* polls FOREVER on a parked proof — and told to stop, it abandons a running one.
|
|
1906
|
+
*
|
|
1907
|
+
* `running` separates them. A proof whose panel is RUNNING is not parked even on a
|
|
1908
|
+
* PENDING_REVIEW row: a sibling proof in the 60-85 band parked the milestone there while
|
|
1909
|
+
* this one's panel still runs, and this one's claim can overwrite PENDING_REVIEW and go
|
|
1910
|
+
* on to COMPLETED. Only a server that sends `running` can say so; without it the old
|
|
1911
|
+
* reading stays.
|
|
1311
1912
|
*/
|
|
1312
1913
|
function parkedForHuman(s) {
|
|
1313
|
-
|
|
1914
|
+
// Never a MilestoneEscrowV3 proof (post-review L2): no human review decides one — its
|
|
1915
|
+
// milestone status is the chain fold's, and a PENDING_REVIEW there is no queue.
|
|
1916
|
+
return s.escrowVersion !== 3 && Boolean(s.pending) && s.running !== true && s.milestoneStatus === "PENDING_REVIEW";
|
|
1314
1917
|
}
|
|
1315
1918
|
|
|
1316
1919
|
const PARKED_MESSAGE =
|
|
@@ -1321,18 +1924,35 @@ const PARKED_MESSAGE =
|
|
|
1321
1924
|
async function handleGetProofStatus({ proofId }) {
|
|
1322
1925
|
const data = await apiGet(`/api/agent/proof-status/${encodeURIComponent(proofId)}`);
|
|
1323
1926
|
|
|
1324
|
-
// `pending` means
|
|
1325
|
-
// are NOT the same thing: verification is still running
|
|
1326
|
-
// verdict and the proof was parked for
|
|
1327
|
-
// left null (`agent/proof-status` keeps it
|
|
1328
|
-
// in a few seconds", an agent polls FOREVER
|
|
1329
|
-
// separates them: a milestone already at
|
|
1330
|
-
//
|
|
1331
|
-
//
|
|
1332
|
-
|
|
1927
|
+
// `pending` means "no verdict on this proof", which has two causes that read the same
|
|
1928
|
+
// and are NOT the same thing: verification is still running (the claim sentinel, or
|
|
1929
|
+
// not claimed yet), or the panel never reached a verdict and the proof was parked for
|
|
1930
|
+
// human review with `aiDecision` deliberately left null (`agent/proof-status` keeps it
|
|
1931
|
+
// null by design there). Told to "poll again in a few seconds", an agent polls FOREVER
|
|
1932
|
+
// on the second one. `milestoneStatus` separates them: a milestone already at
|
|
1933
|
+
// PENDING_REVIEW is waiting on a person — unless `running` says this proof's own panel
|
|
1934
|
+
// is still going, which can still decide that row (`parkedForHuman`).
|
|
1935
|
+
// `src/app/api/mcp/route.ts` has split these since SEC-010; this file, the published
|
|
1936
|
+
// one, did not.
|
|
1937
|
+
//
|
|
1938
|
+
// A CLOSED milestone is asked first: an outage that lost to EXPIRED leaves
|
|
1939
|
+
// `aiDecision` null for good on a row no verdict can move, and "poll again" would
|
|
1940
|
+
// loop forever on it exactly like the parked case.
|
|
1941
|
+
if (data.pending && !nothingLeftToDecide(data)) {
|
|
1333
1942
|
if (parkedForHuman(data)) {
|
|
1334
1943
|
return { ...data, message: `Proof ${proofId}: ${PARKED_MESSAGE}` };
|
|
1335
1944
|
}
|
|
1945
|
+
// An interrupted run (claimPhase "orphaned") reads as `pending` too, and "poll
|
|
1946
|
+
// again" would send the agent into a loop nothing ends — the poll loops stop on
|
|
1947
|
+
// it; this single read has to say it as well (F-50: same field, two doors).
|
|
1948
|
+
if (data.claimPhase === "orphaned") {
|
|
1949
|
+
return {
|
|
1950
|
+
...data,
|
|
1951
|
+
message:
|
|
1952
|
+
`The last verification run for proof ${proofId} was interrupted and nothing is running now. ` +
|
|
1953
|
+
"Once its claim is an hour old, cascrow_verify can start it again — or submit the proof again, which always works.",
|
|
1954
|
+
};
|
|
1955
|
+
}
|
|
1336
1956
|
return {
|
|
1337
1957
|
...data,
|
|
1338
1958
|
message: `Proof ${proofId} is still being processed by the AI. Poll again in a few seconds.`,
|
|
@@ -1342,8 +1962,8 @@ async function handleGetProofStatus({ proofId }) {
|
|
|
1342
1962
|
// SEC-058 / SEC-059, the polling door. `decision === "YES" ? APPROVED : REJECTED`
|
|
1343
1963
|
// answers a question about the PROOF; what an agent needs is the state of the
|
|
1344
1964
|
// MILESTONE, and those come apart in three ways that all read as plain "APPROVED":
|
|
1345
|
-
// the
|
|
1346
|
-
//
|
|
1965
|
+
// the milestone is already done (COMPLETED), it carries an amount nothing can
|
|
1966
|
+
// complete (notCompletableWithoutEscrow), or another proof owns the
|
|
1347
1967
|
// milestone and this verdict changed nothing (SUPERSEDED upstream, which leaves the
|
|
1348
1968
|
// milestone in someone else's terminal state).
|
|
1349
1969
|
const parts = [
|
|
@@ -1360,13 +1980,27 @@ async function handleGetProofStatus({ proofId }) {
|
|
|
1360
1980
|
}
|
|
1361
1981
|
|
|
1362
1982
|
async function handleMcpSubmit({ contract_id, milestone_id, evidence }) {
|
|
1363
|
-
const data = await apiPost("/api/mcp/submit", { contract_id, milestone_id, evidence });
|
|
1983
|
+
const data = await apiPost("/api/mcp/submit", { contract_id, milestone_id, evidence }, MCP_SUBMIT_CLIENT_ABORT_MS);
|
|
1364
1984
|
|
|
1365
1985
|
const confidence = data.confidence ?? 0;
|
|
1366
1986
|
const onChainUrl = data.on_chain_url ?? null;
|
|
1367
1987
|
const proofId = data.proof_id ?? null;
|
|
1368
1988
|
const votes = Array.isArray(data.model_votes) ? data.model_votes : [];
|
|
1369
1989
|
|
|
1990
|
+
// MilestoneEscrowV3: a recommendation, never a payout — asked before every verdict
|
|
1991
|
+
// branch below, none of which may read it as approved or rejected.
|
|
1992
|
+
if (data.action === "V3_RECOMMENDATION") {
|
|
1993
|
+
return {
|
|
1994
|
+
...markVerificationResult({ ...data, model_votes: votes }),
|
|
1995
|
+
verdict: "v3_recommendation",
|
|
1996
|
+
v3Recommendation: true,
|
|
1997
|
+
manifestHash: data.manifestHash ?? null,
|
|
1998
|
+
claimReady: data.claimReady ?? null,
|
|
1999
|
+
submissionVerdict: data.submissionVerdict ?? "PENDING",
|
|
2000
|
+
message: v3RecommendationLine(data.submissionVerdict, data.confidence ?? null),
|
|
2001
|
+
};
|
|
2002
|
+
}
|
|
2003
|
+
|
|
1370
2004
|
// SEC-055, first divergence. `/api/mcp/submit` answers `{ ok: true, decided:
|
|
1371
2005
|
// "elsewhere", … }` at status **200 with no `verdict` key** when the milestone was
|
|
1372
2006
|
// settled by a concurrent request — the investor's review, or a second submission.
|
|
@@ -1376,19 +2010,25 @@ async function handleMcpSubmit({ contract_id, milestone_id, evidence }) {
|
|
|
1376
2010
|
// `src/app/api/mcp/route.ts` has handled this since someone hit it once already;
|
|
1377
2011
|
// this file, the published one, never did.
|
|
1378
2012
|
if (data.decided === "elsewhere") {
|
|
2013
|
+
// `settledAs` names a TERMINAL row (EXPIRED, COMPLETED). EXPIRED is a funder cancel or
|
|
2014
|
+
// the deadline cron, not "the investor's review, or a concurrent submission" — and the
|
|
2015
|
+
// wording must not claim WHEN it closed relative to this submission.
|
|
2016
|
+
const settledAs = typeof data.settledAs === "string" ? data.settledAs : null;
|
|
1379
2017
|
return {
|
|
1380
2018
|
verdict: "decided_elsewhere",
|
|
2019
|
+
...(settledAs ? { settledAs } : {}),
|
|
1381
2020
|
confidence: null,
|
|
1382
2021
|
reasoning: "",
|
|
1383
2022
|
...markVerificationResult({ model_votes: [] }),
|
|
1384
2023
|
on_chain_url: null,
|
|
1385
2024
|
proof_id: proofId,
|
|
1386
|
-
release_hold_until: null,
|
|
1387
2025
|
signed_at: null,
|
|
1388
|
-
message:
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
"
|
|
2026
|
+
message: settledAs
|
|
2027
|
+
? `ℹ️ This milestone was closed (${settledAs}) before this submission could settle it, so this submission changed nothing and moved no funds itself. ` +
|
|
2028
|
+
"Your evidence was recorded. Do not resubmit to this milestone."
|
|
2029
|
+
: "ℹ️ This milestone was decided by another request while your proof was being verified — " +
|
|
2030
|
+
"by the investor's review, or by a concurrent submission. Your evidence was recorded. " +
|
|
2031
|
+
"Check the milestone status before resubmitting; a resubmission will be refused if it is already settled.",
|
|
1392
2032
|
};
|
|
1393
2033
|
}
|
|
1394
2034
|
|
|
@@ -1409,7 +2049,6 @@ async function handleMcpSubmit({ contract_id, milestone_id, evidence }) {
|
|
|
1409
2049
|
confidence: data.confidence ?? null,
|
|
1410
2050
|
on_chain_url: null,
|
|
1411
2051
|
proof_id: proofId,
|
|
1412
|
-
release_hold_until: null,
|
|
1413
2052
|
message:
|
|
1414
2053
|
`⏳ PENDING_REVIEW — the verification panel could not reach a verdict on this submission ` +
|
|
1415
2054
|
`(too few models answered). This is NOT a rejection and NOT a judgement of your work: ` +
|
|
@@ -1420,17 +2059,13 @@ async function handleMcpSubmit({ contract_id, milestone_id, evidence }) {
|
|
|
1420
2059
|
}
|
|
1421
2060
|
|
|
1422
2061
|
const verdict = data.verdict ?? "unknown";
|
|
1423
|
-
//
|
|
1424
|
-
//
|
|
1425
|
-
//
|
|
1426
|
-
// finds out by polling balances — or not at all.
|
|
1427
|
-
const releaseHoldUntil = data.release_hold_until ?? null;
|
|
2062
|
+
// An approved milestone that cannot complete is not a 200: the route answers 409
|
|
2063
|
+
// NOT_COMPLETABLE_WITHOUT_ESCROW, which `apiPost` throws and the error formatter
|
|
2064
|
+
// renders with its hint (`codeHints`).
|
|
1428
2065
|
|
|
1429
2066
|
let statusLine;
|
|
1430
2067
|
if (verdict === "approved") {
|
|
1431
|
-
statusLine =
|
|
1432
|
-
? `⏸️ APPROVED (${confidence}% confidence) — payout scheduled for ${releaseHoldUntil}. NO FUNDS HAVE MOVED YET: the funder can contest until then, and the date can move.`
|
|
1433
|
-
: `✅ APPROVED (${confidence}% confidence)${onChainUrl ? ` — proof: ${onChainUrl}` : ""}`;
|
|
2068
|
+
statusLine = `✅ APPROVED (${confidence}% confidence)${onChainUrl ? ` — proof: ${onChainUrl}` : ""}`;
|
|
1434
2069
|
} else if (verdict === "pending_review") {
|
|
1435
2070
|
statusLine = `⏳ PENDING_REVIEW (${confidence}% confidence) — manual review triggered.`;
|
|
1436
2071
|
} else {
|
|
@@ -1449,7 +2084,6 @@ async function handleMcpSubmit({ contract_id, milestone_id, evidence }) {
|
|
|
1449
2084
|
...markVerificationResult({ reasoning: data.reasoning ?? "", model_votes: votes }),
|
|
1450
2085
|
on_chain_url: onChainUrl,
|
|
1451
2086
|
proof_id: proofId,
|
|
1452
|
-
release_hold_until: releaseHoldUntil,
|
|
1453
2087
|
signed_at: data.signed_at ?? null,
|
|
1454
2088
|
message: `${statusLine}${voteSummary ? `\n\n${voteSummary}` : ""}${data.reasoning ? `\n\n${excerptMarked(data.reasoning, 400)}` : ""}`,
|
|
1455
2089
|
};
|
|
@@ -1489,7 +2123,7 @@ async function handleVerifyWork({ taskDescription, prUrl, codeText, imageBase64,
|
|
|
1489
2123
|
// The body is read INSIDE the deadline. A response whose headers arrived
|
|
1490
2124
|
// and whose body then stalls is otherwise unbounded — a deadline that ends
|
|
1491
2125
|
// at the headers bounds time-to-first-byte and nothing else (SEC-073).
|
|
1492
|
-
return { res: r, data: await r
|
|
2126
|
+
return { res: r, data: await readJsonSafe(r) };
|
|
1493
2127
|
});
|
|
1494
2128
|
if (!res.ok) throw standaloneFailure(res.status, body);
|
|
1495
2129
|
// Assigned to the OUTER `data`, not shadowing it: `const { data }` inside the
|
|
@@ -1511,7 +2145,7 @@ async function handleVerifyWork({ taskDescription, prUrl, codeText, imageBase64,
|
|
|
1511
2145
|
// The body is read INSIDE the deadline. A response whose headers arrived
|
|
1512
2146
|
// and whose body then stalls is otherwise unbounded — a deadline that ends
|
|
1513
2147
|
// at the headers bounds time-to-first-byte and nothing else (SEC-073).
|
|
1514
|
-
return { res: r, data: await r
|
|
2148
|
+
return { res: r, data: await readJsonSafe(r) };
|
|
1515
2149
|
});
|
|
1516
2150
|
if (!res.ok) throw standaloneFailure(res.status, body);
|
|
1517
2151
|
// Assigned to the OUTER `data`, not shadowing it: `const { data }` inside the
|
|
@@ -1545,28 +2179,52 @@ async function handleVerifyWork({ taskDescription, prUrl, codeText, imageBase64,
|
|
|
1545
2179
|
};
|
|
1546
2180
|
}
|
|
1547
2181
|
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
2182
|
+
/**
|
|
2183
|
+
* Poll proof-status until the proof has an outcome worth returning, or `maxMs` runs
|
|
2184
|
+
* out (then `null`). Shared by cascrow_watch_verification (both modes) and by
|
|
2185
|
+
* cascrow_verify when its start lost to a run already going (T01-5).
|
|
2186
|
+
*
|
|
2187
|
+
* What stops the loop early, and why each is safe:
|
|
2188
|
+
* - 401 / 403: waiting does not change who you are.
|
|
2189
|
+
* - 404: permanent. proof/upload answers with a proofId only after the Proof row
|
|
2190
|
+
* exists, and proof-status reads the primary database (there is no replica), so
|
|
2191
|
+
* a 404 means the proof was deleted or erased, not "not there yet".
|
|
2192
|
+
* - `claimPhase: "orphaned"`: the run that held the claim is dead; nothing is
|
|
2193
|
+
* running to wait for.
|
|
2194
|
+
* 429 / 503 wait at least what the server asked (Retry-After), never past `maxMs`.
|
|
2195
|
+
* Anything else — a network error, a 5xx — is transient: poll again.
|
|
2196
|
+
*/
|
|
2197
|
+
async function pollUntilSettled(proofId, maxMs = 100_000, pollMs = 5_000) {
|
|
1559
2198
|
const started = Date.now();
|
|
1560
|
-
|
|
1561
|
-
while (Date.now() - started <
|
|
1562
|
-
await new Promise((r) => setTimeout(r,
|
|
2199
|
+
let waitMs = pollMs;
|
|
2200
|
+
while (Date.now() - started < maxMs) {
|
|
2201
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
2202
|
+
waitMs = pollMs;
|
|
1563
2203
|
let status;
|
|
1564
2204
|
try {
|
|
1565
2205
|
status = await apiGet(`/api/agent/proof-status/${encodeURIComponent(proofId)}`);
|
|
1566
|
-
} catch {
|
|
1567
|
-
|
|
2206
|
+
} catch (e) {
|
|
2207
|
+
const code = e && typeof e.status === "number" ? e.status : null;
|
|
2208
|
+
if (code === 401 || code === 403 || code === 404) {
|
|
2209
|
+
return {
|
|
2210
|
+
pending: true,
|
|
2211
|
+
proofId,
|
|
2212
|
+
stopped: { status: code, code: e.code ?? null },
|
|
2213
|
+
message:
|
|
2214
|
+
code === 404
|
|
2215
|
+
? `Stopped waiting: proof ${proofId} no longer exists (it was deleted or erased). Nothing will verify it.`
|
|
2216
|
+
: `Stopped waiting for proof ${proofId}: ${e.message}. Waiting does not change this — check the API key and that this account is a party to the contract.`,
|
|
2217
|
+
};
|
|
2218
|
+
}
|
|
2219
|
+
if ((code === 429 || code === 503) && typeof e.retryAfterSeconds === "number") {
|
|
2220
|
+
// At least what the server asked, never past this loop's own budget.
|
|
2221
|
+
waitMs = Math.min(Math.max(pollMs, e.retryAfterSeconds * 1000), Math.max(0, maxMs - (Date.now() - started)));
|
|
2222
|
+
}
|
|
2223
|
+
continue; // transient — keep polling
|
|
1568
2224
|
}
|
|
1569
|
-
|
|
2225
|
+
// A closed milestone is terminal whether or not a verdict landed: waiting out the
|
|
2226
|
+
// loop on a row no verdict can move burns the whole budget and then says "poll again".
|
|
2227
|
+
if (!status.pending || nothingLeftToDecide(status)) {
|
|
1570
2228
|
return {
|
|
1571
2229
|
...status,
|
|
1572
2230
|
proofId,
|
|
@@ -1574,24 +2232,68 @@ async function handleWatchVerification({ milestoneId, content, filename }) {
|
|
|
1574
2232
|
};
|
|
1575
2233
|
}
|
|
1576
2234
|
// Parked for a human: `pending` stays true forever on this path, so without this
|
|
1577
|
-
// the loop burns its whole
|
|
2235
|
+
// the loop burns its whole budget and then tells the agent to poll some more.
|
|
1578
2236
|
if (parkedForHuman(status)) {
|
|
1579
2237
|
return { ...status, proofId, message: `Proof ${proofId}: ${PARKED_MESSAGE}` };
|
|
1580
2238
|
}
|
|
2239
|
+
if (status.claimPhase === "orphaned") {
|
|
2240
|
+
return {
|
|
2241
|
+
...status,
|
|
2242
|
+
proofId,
|
|
2243
|
+
message:
|
|
2244
|
+
`The last verification run for proof ${proofId} was interrupted and nothing is running now. ` +
|
|
2245
|
+
"Once its claim is an hour old, cascrow_verify can start it again — or submit the proof again, which always works. " +
|
|
2246
|
+
"(If cascrow_verify then refuses with \"cannot be restarted automatically\", submit the proof again or contact support.)",
|
|
2247
|
+
};
|
|
2248
|
+
}
|
|
1581
2249
|
}
|
|
2250
|
+
return null;
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
function stillRunningResult(proofId) {
|
|
2254
|
+
return {
|
|
2255
|
+
pending: true,
|
|
2256
|
+
proofId,
|
|
2257
|
+
message: `Verification is still in progress. Wait for it with cascrow_watch_verification { proofId: "${proofId}" } or poll cascrow_get_proof_status.`,
|
|
2258
|
+
};
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
async function handleWatchVerification({ milestoneId, content, filename, proofId: existingProofId } = {}) {
|
|
2262
|
+
// Two modes, exactly one per call: SUBMIT (milestoneId + content) uploads and then
|
|
2263
|
+
// waits; ATTACH (proofId) only waits — for a proof already submitted, whose
|
|
2264
|
+
// verification upload started by itself.
|
|
2265
|
+
const attach = typeof existingProofId === "string" && existingProofId.length > 0;
|
|
2266
|
+
const submit = typeof content === "string" && typeof milestoneId === "string";
|
|
2267
|
+
if (attach === submit) {
|
|
2268
|
+
throw new Error(
|
|
2269
|
+
"Give EITHER proofId (wait for a proof already submitted) OR milestoneId + content (submit, then wait) — not both, not neither."
|
|
2270
|
+
);
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
let proofId = existingProofId;
|
|
2274
|
+
if (submit) {
|
|
2275
|
+
const submitData = await uploadProof(milestoneId, filename ?? "proof-report.txt", content);
|
|
2276
|
+
proofId = submitData.proofId;
|
|
2277
|
+
if (!proofId) {
|
|
2278
|
+
throw new Error(submitData.error ?? "Proof submission failed — no proofId returned");
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
const settled = await pollUntilSettled(proofId, 100_000);
|
|
2283
|
+
if (settled) return settled;
|
|
1582
2284
|
|
|
1583
2285
|
// Timed out — return proofId so agent can poll manually
|
|
1584
2286
|
return {
|
|
1585
2287
|
pending: true,
|
|
1586
2288
|
proofId,
|
|
1587
|
-
message: `Verification still in progress after ~100s.
|
|
2289
|
+
message: `Verification still in progress after ~100s. Wait again with cascrow_watch_verification { proofId: "${proofId}" } or poll cascrow_get_proof_status.`,
|
|
1588
2290
|
};
|
|
1589
2291
|
}
|
|
1590
2292
|
|
|
1591
2293
|
// ─── MCP Server ───────────────────────────────────────────────────────────────
|
|
1592
2294
|
|
|
1593
2295
|
const server = new Server(
|
|
1594
|
-
{ name: "cascrow", version: "
|
|
2296
|
+
{ name: "cascrow", version: "3.0.0" },
|
|
1595
2297
|
{ capabilities: { tools: {} } }
|
|
1596
2298
|
);
|
|
1597
2299
|
|
|
@@ -1602,12 +2304,26 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1602
2304
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1603
2305
|
const { name, arguments: args } = request.params;
|
|
1604
2306
|
|
|
2307
|
+
// Removed in 2.0.0 (cascrow Phase 5): the tool sent the agent's private key to
|
|
2308
|
+
// the server. A host with a cached tool list may still call it. Answered before
|
|
2309
|
+
// `args` is touched and in anonymous mode too — the arguments may hold a key,
|
|
2310
|
+
// and nothing here logs, echoes or forwards them.
|
|
2311
|
+
if (REMOVED_CUSTODY_TOOLS.includes(name)) {
|
|
2312
|
+
return { content: [{ type: "text", text: CUSTODY_TOOL_REMOVED }], isError: true };
|
|
2313
|
+
}
|
|
2314
|
+
// Removed in 3.0.0 (cascrow Phase 6, owner decision 16): a $0 contract activates by
|
|
2315
|
+
// itself, and a milestone with an amount is a MilestoneEscrowV3 escrow its payer
|
|
2316
|
+
// funds from their own machine. A cached tool list still calls the old name.
|
|
2317
|
+
if (name === "cascrow_fund_milestone") {
|
|
2318
|
+
return { content: [{ type: "text", text: `Error: ${V3_CLIENT_TEXT.fundRemoved} [TOOL_REMOVED]` }], isError: true };
|
|
2319
|
+
}
|
|
2320
|
+
|
|
1605
2321
|
if (ANONYMOUS_MODE && name !== "cascrow_verify_work") {
|
|
1606
2322
|
return {
|
|
1607
2323
|
content: [{
|
|
1608
2324
|
type: "text",
|
|
1609
2325
|
text: `Error: "${name}" requires a Cascrow account. Register free at https://cascrow.com/register ` +
|
|
1610
|
-
`(or POST /api/agent/register), then set CASCROW_API_KEY to unlock all
|
|
2326
|
+
`(or POST /api/agent/register), then set CASCROW_API_KEY to unlock all ${TOOLS.length} tools. ` +
|
|
1611
2327
|
`Only cascrow_verify_work works anonymously (3 free verifications/day).`,
|
|
1612
2328
|
}],
|
|
1613
2329
|
isError: true,
|
|
@@ -1618,8 +2334,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1618
2334
|
let result;
|
|
1619
2335
|
switch (name) {
|
|
1620
2336
|
case "cascrow_create_contract": result = await handleCreateContract(args); break;
|
|
1621
|
-
case "cascrow_fund_milestone": result = await handleFundMilestone(args); break;
|
|
1622
|
-
case "cascrow_escrow_fund": result = await handleEscrowFund(args); break;
|
|
1623
2337
|
case "cascrow_submit_proof": result = await handleSubmitProof(args); break;
|
|
1624
2338
|
case "cascrow_verify": result = await handleVerify(args); break;
|
|
1625
2339
|
case "cascrow_get_contract": result = await handleGetContract(args); break;
|
|
@@ -1632,6 +2346,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1632
2346
|
case "cascrow_set_discoverable": result = await handleSetDiscoverable(args); break;
|
|
1633
2347
|
case "cascrow_list_my_contracts": result = await handleListMyContracts(args); break;
|
|
1634
2348
|
case "cascrow_review_queue": result = await handleReviewQueue(); break;
|
|
2349
|
+
case "cascrow_v3_claims": result = await handleV3Claims(); break;
|
|
2350
|
+
case "cascrow_v3_offer": result = await handleV3Offer(args); break;
|
|
2351
|
+
case "cascrow_v3_acceptance_get": result = await handleV3AcceptanceGet(args); break;
|
|
2352
|
+
case "cascrow_v3_acceptance_submit": result = await handleV3AcceptanceSubmit(args); break;
|
|
2353
|
+
case "cascrow_v3_prepare": result = await handleV3Prepare(args); break;
|
|
2354
|
+
case "cascrow_v3_report_tx": result = await handleV3ReportTx(args); break;
|
|
2355
|
+
case "cascrow_v3_attestation": result = await handleV3Attestation(args); break;
|
|
1635
2356
|
case "cascrow_review_contract": result = await handleReviewContract(args); break;
|
|
1636
2357
|
case "cascrow_get_proof_status": result = await handleGetProofStatus(args); break;
|
|
1637
2358
|
case "cascrow_mcp_submit": result = await handleMcpSubmit(args); break;
|
|
@@ -1645,8 +2366,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1645
2366
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
1646
2367
|
};
|
|
1647
2368
|
} catch (err) {
|
|
2369
|
+
// A V3 refusal says who can act and how (F-72) — the body's actionable fields (G5),
|
|
2370
|
+
// then the owner's hint, role-aware on the body (H9); the owner's words, copied above.
|
|
2371
|
+
const detail = err instanceof CascrowApiError ? v3RefusalDetail(err.body) : "";
|
|
2372
|
+
const hint = err instanceof CascrowApiError ? v3HintFor(err.code, err.body) : null;
|
|
1648
2373
|
return {
|
|
1649
|
-
content: [{ type: "text", text: `Error: ${err.message}` }],
|
|
2374
|
+
content: [{ type: "text", text: `Error: ${err.message}${detail && !err.message.includes(detail) ? ` — ${detail}` : ""}${hint && !err.message.includes(hint) ? `\n\n${hint}` : ""}` }],
|
|
1650
2375
|
isError: true,
|
|
1651
2376
|
};
|
|
1652
2377
|
}
|