@blindmarket/mcp-server 0.3.4 → 0.3.6

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/dist/rent.js CHANGED
@@ -1,7 +1,14 @@
1
1
  import { z } from 'zod';
2
- import { formatEther, parseEther } from 'ethers';
3
- import { aesEncrypt, eciesEncrypt, generateAesKey, sha256Hex } from './crypto.js';
2
+ import { Contract, Interface, formatUnits, parseUnits } from 'ethers';
3
+ import { aesDecrypt, aesEncrypt, eciesDecrypt, eciesEncrypt, generateAesKey, sha256Hex } from './crypto.js';
4
4
  import { createQuote, consumeQuote, getSpend, putSpend, updateSpend } from './state.js';
5
+ import { createSettlementResolver } from './settlement.js';
6
+ // Read-only view of BlindEscrow.getTask, for reading a task's state directly
7
+ // from the chain that holds it. Field order matches contracts/BlindEscrow.sol;
8
+ // a post-#38 deployment appends disputedAt, which ABI decoding ignores.
9
+ const ESCROW_READ_ABI = [
10
+ 'function getTask(uint256) view returns (tuple(address agent,address worker,address token,uint256 amount,bytes32 taskHash,bytes32 evidenceHash,uint8 status,string category,string locationZone,uint256 createdAt,uint256 deadline,uint8 submissionAttempts))',
11
+ ];
5
12
  /**
6
13
  * Tier-2 spending tools: the CURRENT encrypted post/rent flow, executed
7
14
  * entirely locally. This is a 1:1 port of the "Use from your agent" script
@@ -22,6 +29,16 @@ import { createQuote, consumeQuote, getSpend, putSpend, updateSpend } from './st
22
29
  */
23
30
  const ZERO_TOKEN = '0x0000000000000000000000000000000000000000';
24
31
  const GAS_LIMIT = 1000000n; // matches the canonical rent script
32
+ // Auto-verify releases the payment, so the bar can't be "one character" — but
33
+ // 40 made a correct 30-character URL unpayable. 20 is the platform floor
34
+ // (DEFAULT_MIN_CONTENT_CHARS in the backend's autoVerify, where min_length is a
35
+ // hard floor). Accepted limit: a one-word correct answer still can't
36
+ // auto-verify without an expected_answer. MUST match the web app's
37
+ // RENTAL_VERIFICATION_CRITERIA (frontend/src/components/UseServiceModal.tsx and
38
+ // UseFromAgentModal.tsx) so a rental is judged the same whichever client paid
39
+ // for it. The index route also rejects 'auto' with no real criterion (400
40
+ // AUTO_CRITERIA_REQUIRED).
41
+ const RENTAL_VERIFICATION_CRITERIA = { min_length: 20 };
25
42
  function ok(data) {
26
43
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
27
44
  }
@@ -40,11 +57,34 @@ export function registerRentTools(server, cfg, walletCtx) {
40
57
  err.code = 'NO_API_KEY';
41
58
  throw err;
42
59
  }
43
- const res = await fetch(`${cfg.apiBase}${path}`, {
44
- method,
45
- headers: { 'Content-Type': 'application/json', 'X-API-Key': cfg.apiKey },
46
- body: body ? JSON.stringify(body) : undefined,
47
- });
60
+ // Explicit, named timeout. Node's fetch otherwise fails after ~5 minutes
61
+ // with a bare "fetch failed" — which is what post_task reported, twice,
62
+ // for posts that had already landed on-chain while /a2a/tasks/index sat
63
+ // behind a dead Redis socket server-side. 120s is above the slowest
64
+ // healthy call (index polls for a receipt for ~1 min) and well below the
65
+ // point where a caller assumes the money is lost.
66
+ let res;
67
+ try {
68
+ res = await fetch(`${cfg.apiBase}${path}`, {
69
+ method,
70
+ headers: { 'Content-Type': 'application/json', 'X-API-Key': cfg.apiKey },
71
+ body: body ? JSON.stringify(body) : undefined,
72
+ signal: AbortSignal.timeout(120_000),
73
+ });
74
+ }
75
+ catch (e) {
76
+ // Node's fetch reports every socket-level failure as a bare "fetch
77
+ // failed" and hides the real reason in `cause` (ECONNRESET, socket
78
+ // hang up, ECONNREFUSED…). Surface it: "fetch failed" alone cost a
79
+ // debugging session that ended in "the server restarted mid-request".
80
+ const cause = e?.cause;
81
+ const detail = cause?.code ?? cause?.message ?? e?.message ?? String(e);
82
+ const err = new Error(e?.name === 'TimeoutError'
83
+ ? `${path} did not answer within 120s. The backend may be stalled (check its Redis connection); if this was a spend, retry with the SAME idempotencyKey — it resumes, never double-pays.`
84
+ : `${path} unreachable (${detail}). If the backend restarted mid-request and this was a spend, retry with the SAME idempotencyKey — it resumes from the last persisted stage.`);
85
+ err.code = e?.name === 'TimeoutError' ? 'BACKEND_TIMEOUT' : 'BACKEND_UNREACHABLE';
86
+ throw err;
87
+ }
48
88
  const json = await res.json().catch(() => ({}));
49
89
  if (!res.ok || !json.success) {
50
90
  const err = new Error(`${path} failed: ${json.error?.message || res.status}`);
@@ -53,21 +93,195 @@ export function registerRentTools(server, cfg, walletCtx) {
53
93
  }
54
94
  return json.data;
55
95
  }
56
- function requireWallet() {
96
+ const settlement = createSettlementResolver({ apiBase: cfg.apiBase ?? 'https://api.blindmarket.xyz', api });
97
+ /** How this process pays. On 0G that is the local wallet, which must exist.
98
+ * On Base nothing signs locally — the relay signs from the API key's owner
99
+ * wallet — so a missing BLINDMARKET_PRIVATE_KEY is not an error there. */
100
+ async function requireFunding() {
101
+ let s;
102
+ try {
103
+ s = await settlement();
104
+ }
105
+ catch (err) {
106
+ return { error: fail(err.code ?? 'SETTLEMENT_UNKNOWN', err.message) };
107
+ }
108
+ if (s.mode === 'base')
109
+ return { s, payFrom: s.payFrom };
57
110
  if (!walletCtx) {
58
- return { error: fail('NO_WALLET', 'Spending tools need a local funding wallet — set BLINDMARKET_PRIVATE_KEY (see wallet_status)') };
111
+ return { error: fail('NO_WALLET', 'Spending on 0G needs a local funding wallet — set BLINDMARKET_PRIVATE_KEY (see wallet_status)') };
112
+ }
113
+ return { s, payFrom: walletCtx.wallet.address };
114
+ }
115
+ const ERC20 = new Interface([
116
+ 'function allowance(address owner, address spender) view returns (uint256)',
117
+ 'function approve(address spender, uint256 amount) returns (bool)',
118
+ 'function balanceOf(address owner) view returns (uint256)',
119
+ ]);
120
+ function usdc(s) {
121
+ return new Contract(s.usdcAddress, ERC20, s.provider);
122
+ }
123
+ /** Spendable balance of whoever pays, in the settlement token's units. */
124
+ async function payFromBalance(s, payFrom) {
125
+ try {
126
+ const raw = s.mode === 'base'
127
+ ? await usdc(s).balanceOf(payFrom)
128
+ : await walletCtx.provider.getBalance(payFrom);
129
+ return formatUnits(raw, s.decimals);
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ }
135
+ /** The backend builds every unsigned tx for whichever chain it thinks holds
136
+ * the task, and the tx carries no chainId. Discovery is only a hint about
137
+ * that chain (see settlement.ts). So before broadcasting, check the tx
138
+ * targets the escrow THIS mode expects — otherwise native value goes to a
139
+ * Base address on the 0G RPC, or a 0G refund is relayed onto Base. A
140
+ * mismatch also drops the cached mode so the next call re-asks. */
141
+ async function verifyTarget(s, to, what) {
142
+ const target = String(to).toLowerCase();
143
+ const expected = s.mode === 'base' ? s.escrowAddress : s.escrowAddress;
144
+ if (expected) {
145
+ if (target === expected.toLowerCase())
146
+ return;
147
+ settlement.invalidate();
148
+ const e = new Error(`backend built ${what} for ${to} but this process is in ${s.mode} mode expecting escrow ${expected}. ` +
149
+ (s.mode === 'base'
150
+ ? 'This task is escrowed on 0G — handle it with BLINDMARKET_SETTLEMENT=0g and a local key, or from the web app.'
151
+ : 'The backend is building Base transactions — re-run and discovery will re-check, or set BLINDMARKET_SETTLEMENT=base.'));
152
+ e.code = 'ESCROW_MISMATCH';
153
+ throw e;
154
+ }
155
+ // 0G with no escrow address to compare against (forced 0g, or a backend
156
+ // that doesn't report one). The cheapest truth we have is whether
157
+ // anything lives at `to` on the 0G RPC — a Base escrow address holds no
158
+ // BlindEscrow there.
159
+ const code = await walletCtx.provider.getCode(to).catch(() => '0x');
160
+ if (code === '0x') {
161
+ settlement.invalidate();
162
+ const e = new Error(`backend built ${what} for ${to}, which holds no contract on the 0G RPC — it is almost certainly a Base transaction. Set BLINDMARKET_SETTLEMENT=base.`);
163
+ e.code = 'ESCROW_MISMATCH';
164
+ throw e;
165
+ }
166
+ }
167
+ async function relaySend(s, tx) {
168
+ // gas:'auto' asks the backend to negotiate: user-pays (USDC) → app-pays →
169
+ // wallet-pays, advancing only on Privy's exact refusal for each rung. The
170
+ // negotiation lives server-side on purpose — that is the one place that
171
+ // sees Privy's raw errors, and it is shared with the web app, so both
172
+ // clients behave identically. An earlier version did the fallback here,
173
+ // and it skipped app-pays entirely: the wallet kept paying its own ETH
174
+ // even after sponsorship was switched on in the Privy dashboard.
175
+ const r = await api('POST', '/api/v1/tx/relay-tx', {
176
+ walletAddress: s.payFrom,
177
+ to: tx.to,
178
+ data: tx.data,
179
+ value: tx.value === undefined ? undefined : String(tx.value),
180
+ chain: s.relayChain,
181
+ asset: 'usdc',
182
+ gas: 'auto',
183
+ });
184
+ if (!r?.hash) {
185
+ const e = new Error('relay-tx returned no hash');
186
+ e.code = 'RELAY_NO_HASH';
187
+ throw e;
188
+ }
189
+ // A backend older than the `gas` field answers without it. That backend
190
+ // also has no negotiation, so the only way it succeeds is the explicit
191
+ // default it applies to this body: user-pays.
192
+ return { hash: r.hash, isUserOp: r.isUserOp === true, gas: r.gas ?? 'user-pays' };
193
+ }
194
+ /** Wait for a relayed tx to land. A plain hash can be polled for its receipt;
195
+ * a user-op hash cannot (getTransactionReceipt is always null for it), so
196
+ * that case returns at once and the caller confirms by on-chain STATE —
197
+ * see ensureAllowance and waitCancelled. */
198
+ async function waitRelayed(s, hash, isUserOp) {
199
+ if (isUserOp)
200
+ return;
201
+ for (let i = 0; i < 30; i++) {
202
+ const receipt = await s.provider.getTransactionReceipt(hash).catch(() => null);
203
+ if (receipt) {
204
+ if (receipt.status === 0) {
205
+ const e = new Error(`relayed tx ${hash} reverted`);
206
+ e.code = 'TX_REVERTED';
207
+ throw e;
208
+ }
209
+ return;
210
+ }
211
+ await new Promise((r) => setTimeout(r, 3000));
59
212
  }
60
- return walletCtx;
213
+ const e = new Error(`relayed tx ${hash} not confirmed after 90s — retry with the same idempotencyKey to resume`);
214
+ e.code = 'TX_PENDING';
215
+ throw e;
216
+ }
217
+ /** Base only: createTask pulls USDC via transferFrom, so the escrow needs an
218
+ * allowance first. Confirmed by re-reading allowance() rather than by
219
+ * receipt, which is what makes the user-op case decidable. */
220
+ async function ensureAllowance(s, record) {
221
+ const need = BigInt(record.amountWei);
222
+ const token = usdc(s);
223
+ if ((await token.allowance(s.payFrom, s.escrowAddress)) >= need)
224
+ return;
225
+ const approve = async () => {
226
+ const data = ERC20.encodeFunctionData('approve', [s.escrowAddress, need]);
227
+ const { hash } = await relaySend(s, { to: s.usdcAddress, data });
228
+ // Persist BEFORE waiting: a crash here must resume into the poll below.
229
+ updateSpend(record.idempotencyKey, { stage: 'approved', approveTxHash: hash });
230
+ record.stage = 'approved';
231
+ record.approveTxHash = hash;
232
+ };
233
+ const settled = async () => {
234
+ for (let i = 0; i < 30; i++) {
235
+ if ((await token.allowance(s.payFrom, s.escrowAddress)) >= need)
236
+ return true;
237
+ await new Promise((r) => setTimeout(r, 3000));
238
+ }
239
+ return false;
240
+ };
241
+ if (record.stage === 'created') {
242
+ await approve();
243
+ if (await settled())
244
+ return;
245
+ }
246
+ // Resumed at 'approved' (or the fresh approve never landed): the earlier
247
+ // approve was dropped or reverted. Sending another is safe — ERC-20
248
+ // approve SETS the allowance, it does not add — and it is the only way
249
+ // out of this stage, so do it rather than leave the record stuck.
250
+ if (await settled())
251
+ return;
252
+ await approve();
253
+ if (await settled())
254
+ return;
255
+ const e = new Error(`USDC allowance still below ${formatUnits(need, s.decimals)} after two approves (last ${record.approveTxHash}) — check the relay wallet's USDC balance and retry with the same idempotencyKey`);
256
+ e.code = 'APPROVE_PENDING';
257
+ throw e;
61
258
  }
62
259
  /** Fund escrow + index — the shared tail of rent_service and post_task.
63
- * Resumable at every stage via the spend ledger. */
260
+ * Resumable at every stage via the spend ledger.
261
+ *
262
+ * Two funding paths, chosen by settlement mode:
263
+ * 0g — native value from the local wallet, signed and sent here.
264
+ * base — USDC via transferFrom: approve first (ensureAllowance), then the
265
+ * createTask the backend built, both through the Privy relay with
266
+ * no local signing at all. The backend picks the escrow; we only
267
+ * check it is the one we approved. */
64
268
  async function fundAndIndex(record) {
65
- const ctx = walletCtx;
269
+ const s = await settlement();
66
270
  let { txHash } = record;
67
- if (record.stage === 'created') {
271
+ // A record remembers the chain it started on. If the backend flips mode
272
+ // between attempts, re-funding through the other path would double-fund
273
+ // or send native value into a USDC transferFrom — refuse instead.
274
+ if (record.settlement && record.settlement !== s.mode) {
275
+ const e = new Error(`spend ${record.idempotencyKey} started on ${record.settlement} but the backend now settles on ${s.mode} — finish or refund it from the web app`);
276
+ e.code = 'SETTLEMENT_CHANGED';
277
+ throw e;
278
+ }
279
+ if (record.stage === 'created' || record.stage === 'approved') {
280
+ if (s.mode === 'base')
281
+ await ensureAllowance(s, record);
68
282
  const { unsignedTx } = await api('POST', '/api/v1/tasks', {
69
283
  taskHash: record.taskHash,
70
- token: ZERO_TOKEN,
284
+ token: s.mode === 'base' ? s.usdcAddress : ZERO_TOKEN,
71
285
  amount: record.amountWei,
72
286
  locationZone: 'global',
73
287
  duration: String(record.durationSecs ?? 3600),
@@ -78,22 +292,39 @@ export function registerRentTools(server, cfg, walletCtx) {
78
292
  rootHash: record.rootHash,
79
293
  wrappedKeys: record.privacy === 'public' ? undefined : record.wrappedKeys,
80
294
  });
81
- const tx = await ctx.wallet.sendTransaction({
82
- to: unsignedTx.to,
83
- data: unsignedTx.data,
84
- value: BigInt(record.amountWei),
85
- gasLimit: GAS_LIMIT,
86
- });
87
- // Persist the tx hash BEFORE waiting: if we crash mid-confirmation the
88
- // resume path re-runs /tasks/index with this hash instead of re-funding.
89
- updateSpend(record.idempotencyKey, { stage: 'funded', txHash: tx.hash });
90
- txHash = tx.hash;
91
- await tx.wait();
295
+ // Either branch: the tx must target the escrow this mode expects. On
296
+ // Base that is also the escrow the allowance above was granted to.
297
+ await verifyTarget(s, unsignedTx.to, 'createTask');
298
+ if (s.mode === 'base') {
299
+ const { hash, isUserOp, gas } = await relaySend(s, { to: unsignedTx.to, data: unsignedTx.data });
300
+ // Persist BEFORE waiting, same reasoning as the 0G branch below.
301
+ updateSpend(record.idempotencyKey, { stage: 'funded', txHash: hash, isUserOp, gas });
302
+ record.gas = gas;
303
+ txHash = hash;
304
+ record.isUserOp = isUserOp;
305
+ await waitRelayed(s, hash, isUserOp);
306
+ }
307
+ else {
308
+ const tx = await walletCtx.wallet.sendTransaction({
309
+ to: unsignedTx.to,
310
+ data: unsignedTx.data,
311
+ value: BigInt(record.amountWei),
312
+ gasLimit: GAS_LIMIT,
313
+ });
314
+ // Persist the tx hash BEFORE waiting: if we crash mid-confirmation the
315
+ // resume path re-runs /tasks/index with this hash instead of re-funding.
316
+ updateSpend(record.idempotencyKey, { stage: 'funded', txHash: tx.hash });
317
+ txHash = tx.hash;
318
+ await tx.wait();
319
+ }
92
320
  }
93
321
  // stage 'funded' (fresh or resumed): index against the verified receipt.
94
- // /a2a/tasks/index itself polls for the receipt server-side.
322
+ // /a2a/tasks/index polls both chains for the receipt server-side, and with
323
+ // isUserOp it skips the (always-null) receipt lookup and scans logs for the
324
+ // TaskCreated event instead — that is how the user-op case is resolved.
95
325
  await api('POST', '/api/v1/a2a/tasks/index', {
96
326
  txHash,
327
+ isUserOp: record.isUserOp ?? false,
97
328
  taskHash: record.taskHash,
98
329
  verificationMode: record.verificationMode,
99
330
  verificationCriteria: record.verificationCriteria,
@@ -106,7 +337,7 @@ export function registerRentTools(server, cfg, walletCtx) {
106
337
  publicBrief: record.privacy === 'public' ? record.publicBrief : undefined,
107
338
  });
108
339
  updateSpend(record.idempotencyKey, { stage: 'indexed' });
109
- return { taskHash: record.taskHash, txHash: txHash };
340
+ return { taskHash: record.taskHash, txHash: txHash, gas: record.gas };
110
341
  }
111
342
  async function pollPosted(taskHash, waitSeconds) {
112
343
  const deadline = Date.now() + Math.min(60, Math.max(0, waitSeconds)) * 1000;
@@ -130,7 +361,7 @@ export function registerRentTools(server, cfg, walletCtx) {
130
361
  // ── rent_service ──────────────────────────────────────────────────────────
131
362
  server.registerTool('rent_service', {
132
363
  title: 'Rent an Agent Service',
133
- description: 'Hire a listed agent service for one call: encrypts your prompt locally (unless privacy=public), funds escrow from your local wallet, and pins the task to the provider agent. TWO-STEP: first call returns a price quote + quoteId; re-call with confirm=true and that quoteId to actually spend. Requires a unique idempotencyKey (safe to retry with the same key — it resumes, never double-pays).',
364
+ description: 'Hire a listed agent service for one call: encrypts your prompt locally (unless privacy=public), funds escrow, and pins the task to the provider agent. Escrow is USDC on Base via the gas-sponsored relay when the backend settles there (no private key needed), else native 0G from the local wallet — see wallet_status. TWO-STEP: first call returns a price quote + quoteId; re-call with confirm=true and that quoteId to actually spend. Requires a unique idempotencyKey (safe to retry with the same key — it resumes, never double-pays).',
134
365
  inputSchema: {
135
366
  serviceId: z.number().int().positive().describe('Service id from browse_services / get_service'),
136
367
  prompt: z.string().min(1).max(100_000).describe('What you want the agent to do'),
@@ -142,9 +373,10 @@ export function registerRentTools(server, cfg, walletCtx) {
142
373
  },
143
374
  annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
144
375
  }, async ({ serviceId, prompt, idempotencyKey, privacy, confirm, quoteId, waitSeconds }) => {
145
- const w = requireWallet();
146
- if ('error' in w)
147
- return w.error;
376
+ const f = await requireFunding();
377
+ if ('error' in f)
378
+ return f.error;
379
+ const { s, payFrom } = f;
148
380
  // Resume path — this key already spent (or partially spent).
149
381
  const existing = getSpend(idempotencyKey);
150
382
  if (existing) {
@@ -166,15 +398,29 @@ export function registerRentTools(server, cfg, walletCtx) {
166
398
  if (!isPublic && !service.agent_public_key) {
167
399
  return fail('NO_AGENT_PUBKEY', 'This service\'s agent has no encryption public key — only privacy=public calls are possible');
168
400
  }
401
+ // price_raw is stored in the settlement token's base units (the backend
402
+ // compares it against the on-chain amount as-is), so it is 6-decimal
403
+ // USDC on Base and 18-decimal 0G otherwise — format it that way.
404
+ const priceRaw = BigInt(service.price_raw);
405
+ // A service priced in 18-decimal units on a 6-decimal chain would quote
406
+ // as a trillion USDC and relay an approve for it before createTask
407
+ // failed. The backend converts such prices since migration 31, but an
408
+ // older backend may still serve one. 1,000,000 USDC per call is far
409
+ // above any real listing — refuse.
410
+ if (s.mode === 'base' && priceRaw > 1000000n * 10n ** 6n) {
411
+ return fail('PRICE_UNITS_SUSPECT', `service ${serviceId} lists price_raw=${priceRaw} which is ${formatUnits(priceRaw, 6)} USDC — this looks like an 18-decimal 0G price on a USDC chain. Not sending. Re-list the service in USDC base units.`);
412
+ }
413
+ const price = formatUnits(priceRaw, s.decimals);
169
414
  if (!confirm) {
170
- const balance = await w.provider.getBalance(w.wallet.address).catch(() => null);
171
- const quote = createQuote('rent', { serviceId, price0G: formatEther(service.price_raw) });
415
+ const quote = createQuote('rent', { serviceId, price, currency: s.symbol });
172
416
  return ok({
173
417
  quote: {
174
418
  service: { id: service.id, name: service.name, agent: service.agent_address },
175
- price0G: formatEther(service.price_raw),
176
- payFrom: w.wallet.address,
177
- walletBalance0G: balance === null ? null : formatEther(balance),
419
+ price,
420
+ currency: s.symbol,
421
+ settlement: s.mode,
422
+ payFrom,
423
+ walletBalance: await payFromBalance(s, payFrom),
178
424
  privacy: isPublic ? 'public' : 'private',
179
425
  quoteId: quote.quoteId,
180
426
  },
@@ -217,9 +463,11 @@ export function registerRentTools(server, cfg, walletCtx) {
217
463
  wrappedKeys,
218
464
  publicBrief: isPublic ? prompt.slice(0, 4000) : undefined,
219
465
  verificationMode: 'auto',
220
- verificationCriteria: { min_length: 1 },
466
+ verificationCriteria: RENTAL_VERIFICATION_CRITERIA,
221
467
  requiredCapabilities: [],
222
468
  amountWei: String(service.price_raw),
469
+ settlement: s.mode,
470
+ token: s.mode === 'base' ? s.usdcAddress : ZERO_TOKEN,
223
471
  durationSecs: 3600,
224
472
  createdAt: new Date().toISOString(),
225
473
  updatedAt: new Date().toISOString(),
@@ -232,7 +480,7 @@ export function registerRentTools(server, cfg, walletCtx) {
232
480
  catch (err) {
233
481
  const code = err.code;
234
482
  if (code === 'NOT_TASK_AGENT') {
235
- return fail(code, 'The API key\'s owner wallet does not match the funding wallet (BLINDMARKET_PRIVATE_KEY). Mint an sk_ key while signed in with the funding wallet. The escrow is funded but unindexed — retry with the same idempotencyKey after fixing the key, or cancel on-chain for a refund.');
483
+ return fail(code, 'The API key\'s owner wallet does not match the wallet that funded escrow. On 0G, mint an sk_ key while signed in with the BLINDMARKET_PRIVATE_KEY wallet; on Base the relay signs from the key\'s own wallet, so this means the key was rotated mid-spend. The escrow is funded but unindexed — retry with the same idempotencyKey after fixing the key, or use cancel_task for a refund.');
236
484
  }
237
485
  return fail(code ?? 'RENT_FAILED', err.message);
238
486
  }
@@ -240,10 +488,11 @@ export function registerRentTools(server, cfg, walletCtx) {
240
488
  // ── post_task ─────────────────────────────────────────────────────────────
241
489
  server.registerTool('post_task', {
242
490
  title: 'Post a Task to the Open Market',
243
- description: 'Post a task any matching agent can pick up: encrypts the brief locally and wraps its key to every registered matching executor (or posts it in plaintext with privacy=public), then funds escrow from your local wallet. TWO-STEP quote/confirm like rent_service; requires a unique idempotencyKey.',
491
+ description: 'Post a task any matching agent can pick up: encrypts the brief locally and wraps its key to every registered matching executor (or posts it in plaintext with privacy=public), then funds escrow. Escrow is USDC on Base via the gas-sponsored relay when the backend settles there (no private key needed), else native 0G from the local wallet — see wallet_status. TWO-STEP quote/confirm like rent_service; requires a unique idempotencyKey.',
244
492
  inputSchema: {
245
493
  instructions: z.string().min(1).max(100_000).describe('The task brief'),
246
- amount0G: z.string().regex(/^\d+(\.\d+)?$/).describe('Escrow amount in 0G (e.g. "2.5") — paid to the worker (90%) on verified completion'),
494
+ amount: z.string().regex(/^\d+(\.\d+)?$/).optional().describe('Escrow amount in the settlement token (e.g. "2.5" — USDC on Base, 0G on 0G) — paid to the worker (90%) on verified completion'),
495
+ amount0G: z.string().regex(/^\d+(\.\d+)?$/).optional().describe('Deprecated alias of `amount` kept for existing callers — same meaning, same units as the settlement token'),
247
496
  idempotencyKey: z.string().min(8).max(128).describe('Unique key for this spend — reuse it on retries'),
248
497
  capabilities: z.array(z.string()).optional().describe('Optional capability tags to route to matching agents first; empty = every agent'),
249
498
  durationSeconds: z.number().int().min(3600).max(90 * 24 * 3600).optional().describe('Deadline seconds from now (default 86400 = 24h)'),
@@ -252,10 +501,11 @@ export function registerRentTools(server, cfg, walletCtx) {
252
501
  quoteId: z.string().optional().describe('From the quote step'),
253
502
  },
254
503
  annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
255
- }, async ({ instructions, amount0G, idempotencyKey, capabilities, durationSeconds, privacy, confirm, quoteId }) => {
256
- const w = requireWallet();
257
- if ('error' in w)
258
- return w.error;
504
+ }, async ({ instructions, amount, amount0G, idempotencyKey, capabilities, durationSeconds, privacy, confirm, quoteId }) => {
505
+ const f = await requireFunding();
506
+ if ('error' in f)
507
+ return f.error;
508
+ const { s, payFrom } = f;
259
509
  const existing = getSpend(idempotencyKey);
260
510
  if (existing) {
261
511
  if (existing.stage === 'indexed') {
@@ -269,15 +519,26 @@ export function registerRentTools(server, cfg, walletCtx) {
269
519
  }
270
520
  }
271
521
  const isPublic = privacy === 'public';
272
- const amountWei = parseEther(amount0G);
522
+ const amountStr = amount ?? amount0G;
523
+ if (!amountStr) {
524
+ return fail('AMOUNT_REQUIRED', `Pass \`amount\` — the escrow in ${s.symbol} (e.g. "2.5").`);
525
+ }
526
+ let amountWei;
527
+ try {
528
+ amountWei = parseUnits(amountStr, s.decimals);
529
+ }
530
+ catch {
531
+ return fail('AMOUNT_INVALID', `"${amountStr}" is not a valid ${s.symbol} amount — at most ${s.decimals} decimal places.`);
532
+ }
273
533
  if (!confirm) {
274
- const balance = await w.provider.getBalance(w.wallet.address).catch(() => null);
275
- const quote = createQuote('post', { amount0G });
534
+ const quote = createQuote('post', { amount: amountStr, currency: s.symbol });
276
535
  return ok({
277
536
  quote: {
278
- escrow0G: amount0G,
279
- payFrom: w.wallet.address,
280
- walletBalance0G: balance === null ? null : formatEther(balance),
537
+ escrow: amountStr,
538
+ currency: s.symbol,
539
+ settlement: s.mode,
540
+ payFrom,
541
+ walletBalance: await payFromBalance(s, payFrom),
281
542
  privacy: isPublic ? 'public' : 'private',
282
543
  capabilities: capabilities ?? [],
283
544
  quoteId: quote.quoteId,
@@ -333,6 +594,8 @@ export function registerRentTools(server, cfg, walletCtx) {
333
594
  verificationCriteria: { min_length: 10, pass_threshold: 60 },
334
595
  requiredCapabilities: capabilities ?? [],
335
596
  amountWei: amountWei.toString(),
597
+ settlement: s.mode,
598
+ token: s.mode === 'base' ? s.usdcAddress : ZERO_TOKEN,
336
599
  durationSecs: durationSeconds ?? 86400,
337
600
  createdAt: new Date().toISOString(),
338
601
  updatedAt: new Date().toISOString(),
@@ -344,7 +607,7 @@ export function registerRentTools(server, cfg, walletCtx) {
344
607
  catch (err) {
345
608
  const code = err.code;
346
609
  if (code === 'NOT_TASK_AGENT') {
347
- return fail(code, 'The API key\'s owner wallet does not match the funding wallet (BLINDMARKET_PRIVATE_KEY). Mint an sk_ key while signed in with the funding wallet. The escrow is funded but unindexed — retry with the same idempotencyKey after fixing the key, or cancel on-chain for a refund.');
610
+ return fail(code, 'The API key\'s owner wallet does not match the wallet that funded escrow. On 0G, mint an sk_ key while signed in with the BLINDMARKET_PRIVATE_KEY wallet; on Base the relay signs from the key\'s own wallet, so this means the key was rotated mid-spend. The escrow is funded but unindexed — retry with the same idempotencyKey after fixing the key, or use cancel_task for a refund.');
348
611
  }
349
612
  return fail(code ?? 'POST_FAILED', err.message);
350
613
  }
@@ -359,5 +622,527 @@ export function registerRentTools(server, cfg, walletCtx) {
359
622
  },
360
623
  annotations: { readOnlyHint: true, openWorldHint: true },
361
624
  }, async ({ taskHash, waitSeconds }) => ok(await pollPosted(taskHash, waitSeconds ?? 30)));
625
+ // ── cancel_task / claim_timeout ───────────────────────────────────────────
626
+ //
627
+ // The two ways escrow comes back to the poster. BlindEscrow splits them by
628
+ // task status and they are NOT interchangeable — calling the wrong one
629
+ // reverts on-chain:
630
+ //
631
+ // Funded (0) → cancelTask, no deadline required
632
+ // Assigned/Submitted/Verified (1-3) → claimTimeout, deadline must have passed
633
+ // Disputed (6) → claimTimeout, but only once
634
+ // DISPUTE_WINDOW has elapsed
635
+ //
636
+ // So the quote step reads the live status first and refuses a path the
637
+ // contract would reject, rather than letting the caller burn gas to find out.
638
+ //
639
+ // Both send a transaction, so both carry the same quote/confirm +
640
+ // idempotencyKey treatment as post_task. Here the ledger's job is to stop a
641
+ // retry from broadcasting a SECOND refund tx while the first is still
642
+ // unconfirmed — the first would land and the second would revert.
643
+ //
644
+ // Chain note: the backend resolves which chain holds the task
645
+ // (resolveTaskChainById) and builds the unsigned tx for it; this side only
646
+ // decides who signs — the local wallet on 0G, the Privy relay on Base — and
647
+ // verifies the tx targets the escrow that mode expects before sending
648
+ // (verifyTarget). Task state is read from the chain the mode names, not
649
+ // from GET /tasks/:id, which is bound to the 0G escrow.
650
+ const STATUS_NAMES = ['Funded', 'Assigned', 'Submitted', 'Verified', 'Completed', 'Cancelled', 'Disputed'];
651
+ function statusName(status) {
652
+ return STATUS_NAMES[status] ?? `Unknown(${status})`;
653
+ }
654
+ /** Resolve a task id-or-hash to its live on-chain state.
655
+ *
656
+ * GET /api/v1/tasks/:id accepts either form — but it reads the 0G escrow
657
+ * only (escrowService.getTask is bound to the 0G contract), so on Base its
658
+ * struct is for whatever 0G task happens to share the id. In Base mode we
659
+ * use it just to turn a hash into an id, then read the struct from the
660
+ * Base escrow ourselves over the read-only provider. */
661
+ async function loadTask(s, task) {
662
+ if (s.mode !== 'base') {
663
+ return api('GET', `/api/v1/tasks/${encodeURIComponent(task)}`);
664
+ }
665
+ // Base: the escrow is the authority, and we can read it directly. Only ask
666
+ // the backend when we need a hash resolved to an id — that endpoint makes
667
+ // several Redis round trips and is the slowest thing in this path, so a
668
+ // numeric id must not pay for it.
669
+ let taskId;
670
+ if (/^\d+$/.test(task)) {
671
+ taskId = task;
672
+ }
673
+ else {
674
+ const viaBackend = await api('GET', `/api/v1/tasks/${encodeURIComponent(task)}`);
675
+ taskId = viaBackend.taskId;
676
+ }
677
+ const escrow = new Contract(s.escrowAddress, ESCROW_READ_ABI, s.provider);
678
+ const t = await escrow.getTask(BigInt(taskId));
679
+ if (String(t.agent).toLowerCase() === ZERO_TOKEN) {
680
+ const e = new Error(`task ${taskId} does not exist on the Base escrow ${s.escrowAddress} — it is probably a 0G task; handle it with BLINDMARKET_SETTLEMENT=0g`);
681
+ e.code = 'TASK_NOT_ON_BASE';
682
+ throw e;
683
+ }
684
+ return {
685
+ taskId,
686
+ taskHash: String(t.taskHash),
687
+ status: Number(t.status),
688
+ amount: String(t.amount),
689
+ deadline: String(t.deadline),
690
+ token: String(t.token),
691
+ submissionAttempts: Number(t.submissionAttempts),
692
+ decimals: s.decimals,
693
+ };
694
+ }
695
+ /** Amount formatted against the task's OWN decimals — a Base task settles in
696
+ * USDC (6), not the wallet's native 18. */
697
+ function refundAmount(detail) {
698
+ return formatUnits(BigInt(detail.amount), detail.decimals ?? 18);
699
+ }
700
+ /** The refund has landed when the task reads Cancelled on-chain. Checking
701
+ * state rather than a receipt is what makes a relayed user-op decidable
702
+ * (there is no receipt to poll), and it is a cheap truth check for the
703
+ * local-signing path too. */
704
+ async function waitCancelled(s, taskId) {
705
+ for (let i = 0; i < 30; i++) {
706
+ const detail = await loadTask(s, String(taskId)).catch(() => null);
707
+ if (detail && Number(detail.status) === 5)
708
+ return;
709
+ await new Promise((r) => setTimeout(r, 3000));
710
+ }
711
+ const e = new Error(`task ${taskId} still not Cancelled on-chain after 90s — retry with the same idempotencyKey to keep waiting`);
712
+ e.code = 'REFUND_PENDING';
713
+ throw e;
714
+ }
715
+ /** Broadcast the refund and wait for it — the shared tail of both tools.
716
+ * Resumable: a record past 'created' waits on the tx it already saved.
717
+ * Same two paths as fundAndIndex: local wallet on 0G, Privy relay on Base.
718
+ * The backend resolves which chain holds the task and builds the tx for
719
+ * it; this only decides who signs. */
720
+ async function sendRefund(record) {
721
+ const s = await settlement();
722
+ const taskId = record.taskId;
723
+ let { txHash } = record;
724
+ if (record.settlement && record.settlement !== s.mode) {
725
+ const e = new Error(`refund ${record.idempotencyKey} started on ${record.settlement} but the backend now settles on ${s.mode} — finish it from the web app`);
726
+ e.code = 'SETTLEMENT_CHANGED';
727
+ throw e;
728
+ }
729
+ if (record.stage === 'created') {
730
+ const route = record.kind === 'cancel' ? 'cancel' : 'timeout';
731
+ const { unsignedTx } = await api('POST', `/api/v1/tasks/${taskId}/${route}`);
732
+ // The backend resolves the chain that holds the task and builds for it;
733
+ // the tx carries no chainId. If that chain is not the one this mode
734
+ // broadcasts on, stop here — relaying a 0G refund onto Base lands on an
735
+ // address with no escrow and burns the gas.
736
+ await verifyTarget(s, unsignedTx.to, `${route}Task`);
737
+ if (s.mode === 'base') {
738
+ const { hash, isUserOp, gas } = await relaySend(s, { to: unsignedTx.to, data: unsignedTx.data });
739
+ // Persist BEFORE waiting, same reasoning as the 0G branch below.
740
+ updateSpend(record.idempotencyKey, { stage: 'sent', txHash: hash, isUserOp, gas });
741
+ record.gas = gas;
742
+ txHash = hash;
743
+ record.isUserOp = isUserOp;
744
+ await waitRelayed(s, hash, isUserOp);
745
+ }
746
+ else {
747
+ const tx = await walletCtx.wallet.sendTransaction({
748
+ to: unsignedTx.to,
749
+ data: unsignedTx.data,
750
+ gasLimit: GAS_LIMIT,
751
+ });
752
+ // Persist the hash BEFORE waiting, same reasoning as fundAndIndex: a
753
+ // crash mid-confirmation must resume onto THIS tx, not broadcast another.
754
+ updateSpend(record.idempotencyKey, { stage: 'sent', txHash: tx.hash });
755
+ txHash = tx.hash;
756
+ await tx.wait();
757
+ }
758
+ }
759
+ else if (txHash) {
760
+ if (s.mode === 'base')
761
+ await waitRelayed(s, txHash, record.isUserOp ?? false);
762
+ else
763
+ await walletCtx.provider.waitForTransaction(txHash);
764
+ }
765
+ else {
766
+ throw new Error(`Spend record ${record.idempotencyKey} is at stage '${record.stage}' with no txHash — cannot resume safely`);
767
+ }
768
+ await waitCancelled(s, taskId);
769
+ updateSpend(record.idempotencyKey, { stage: 'confirmed' });
770
+ return { taskId, txHash: txHash, gas: record.gas };
771
+ }
772
+ /** Shared resume arm: an idempotencyKey that has already moved. The kind
773
+ * guard matters — the ledger is one namespace shared with rent/post, and
774
+ * resuming a 'post' record through the refund path would wait on the FUNDING
775
+ * tx and then mark that record confirmed, corrupting it. */
776
+ async function resumeRefund(existing, kind) {
777
+ if (existing.kind !== kind) {
778
+ return fail('IDEMPOTENCY_KEY_REUSED', `idempotencyKey "${existing.idempotencyKey}" already belongs to a '${existing.kind}' spend (task ${existing.taskId ?? existing.taskHash}). Use a fresh key for this refund.`);
779
+ }
780
+ if (existing.stage === 'confirmed') {
781
+ return ok({
782
+ resumed: true,
783
+ taskId: existing.taskId,
784
+ txHash: existing.txHash,
785
+ hint: 'Already refunded — this idempotencyKey completed earlier.',
786
+ });
787
+ }
788
+ try {
789
+ return ok({ resumed: true, ...(await sendRefund(existing)) });
790
+ }
791
+ catch (err) {
792
+ return fail(err.code ?? 'RESUME_FAILED', err.message);
793
+ }
794
+ }
795
+ server.registerTool('cancel_task', {
796
+ title: 'Cancel a Task and Reclaim Escrow',
797
+ description: 'Reclaim the escrow on a task you posted that has NOT been assigned to a worker (status Funded). Works immediately — no deadline wait. For a task that was assigned but never delivered, use claim_timeout instead. TWO-STEP quote/confirm; requires a unique idempotencyKey.',
798
+ inputSchema: {
799
+ task: z.string().min(1).describe('Task id (e.g. "51") or the 0x task hash returned by post_task'),
800
+ idempotencyKey: z.string().min(8).max(128).describe('Unique key for this refund — reuse it on retries'),
801
+ confirm: z.boolean().optional().describe('Set true (with quoteId) to send the transaction'),
802
+ quoteId: z.string().optional().describe('From the quote step'),
803
+ },
804
+ annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
805
+ }, async ({ task, idempotencyKey, confirm, quoteId }) => {
806
+ const f = await requireFunding();
807
+ if ('error' in f)
808
+ return f.error;
809
+ const { s, payFrom } = f;
810
+ const existing = getSpend(idempotencyKey);
811
+ if (existing)
812
+ return resumeRefund(existing, 'cancel');
813
+ let detail;
814
+ try {
815
+ detail = await loadTask(s, task);
816
+ }
817
+ catch (err) {
818
+ return fail(err.code ?? 'TASK_LOOKUP_FAILED', err.message);
819
+ }
820
+ const status = Number(detail.status);
821
+ if (status !== 0) {
822
+ const alt = status >= 1 && status <= 3
823
+ ? ' Use claim_timeout instead, once the deadline has passed.'
824
+ : ' The escrow is already settled — nothing to reclaim.';
825
+ return fail('WRONG_REFUND_PATH', `Task ${detail.taskId} is ${statusName(status)}, and cancelTask only accepts Funded tasks.${alt}`);
826
+ }
827
+ if (!confirm) {
828
+ const quote = createQuote('cancel', { taskId: detail.taskId });
829
+ return ok({
830
+ quote: {
831
+ action: 'cancelTask',
832
+ taskId: detail.taskId,
833
+ status: statusName(status),
834
+ refund: refundAmount(detail),
835
+ refundTo: payFrom,
836
+ settlement: s.mode,
837
+ quoteId: quote.quoteId,
838
+ },
839
+ next: `Re-call cancel_task with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to send it.`,
840
+ });
841
+ }
842
+ if (!quoteId || !consumeQuote(quoteId, 'cancel')) {
843
+ return fail('QUOTE_REQUIRED', 'Get a quote first (call without confirm), then re-call with confirm=true and the returned quoteId (quotes are single-use and expire after 10 minutes)');
844
+ }
845
+ try {
846
+ const record = {
847
+ idempotencyKey,
848
+ kind: 'cancel',
849
+ stage: 'created',
850
+ settlement: s.mode,
851
+ taskId: Number(detail.taskId),
852
+ taskHash: detail.taskHash,
853
+ amountWei: detail.amount,
854
+ createdAt: new Date().toISOString(),
855
+ updatedAt: new Date().toISOString(),
856
+ };
857
+ putSpend(record);
858
+ const done = await sendRefund(record);
859
+ return ok({ ...done, refunded: refundAmount(detail), hint: 'Escrow returned to the posting wallet.' });
860
+ }
861
+ catch (err) {
862
+ return fail(err.code ?? 'CANCEL_FAILED', err.message);
863
+ }
864
+ });
865
+ server.registerTool('claim_timeout', {
866
+ title: 'Reclaim Escrow After the Deadline',
867
+ description: 'Reclaim the escrow on a task that WAS assigned but never completed, once its deadline has passed (status Assigned, Submitted, or Verified-failed). For a task no worker ever picked up, use cancel_task instead — it needs no deadline. TWO-STEP quote/confirm; requires a unique idempotencyKey.',
868
+ inputSchema: {
869
+ task: z.string().min(1).describe('Task id (e.g. "51") or the 0x task hash returned by post_task'),
870
+ idempotencyKey: z.string().min(8).max(128).describe('Unique key for this refund — reuse it on retries'),
871
+ confirm: z.boolean().optional().describe('Set true (with quoteId) to send the transaction'),
872
+ quoteId: z.string().optional().describe('From the quote step'),
873
+ },
874
+ annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
875
+ }, async ({ task, idempotencyKey, confirm, quoteId }) => {
876
+ const f = await requireFunding();
877
+ if ('error' in f)
878
+ return f.error;
879
+ const { s, payFrom } = f;
880
+ const existing = getSpend(idempotencyKey);
881
+ if (existing)
882
+ return resumeRefund(existing, 'timeout');
883
+ let detail;
884
+ try {
885
+ detail = await loadTask(s, task);
886
+ }
887
+ catch (err) {
888
+ return fail(err.code ?? 'TASK_LOOKUP_FAILED', err.message);
889
+ }
890
+ const status = Number(detail.status);
891
+ if (status === 0) {
892
+ return fail('WRONG_REFUND_PATH', `Task ${detail.taskId} is Funded — claimTimeout reverts on Funded tasks because no worker was ever assigned. Use cancel_task, which works right now with no deadline wait.`);
893
+ }
894
+ if (status === 4 || status === 5) {
895
+ return fail('NOTHING_TO_REFUND', `Task ${detail.taskId} is ${statusName(status)} — the escrow is already settled.`);
896
+ }
897
+ const deadline = BigInt(detail.deadline);
898
+ const now = BigInt(Math.floor(Date.now() / 1000));
899
+ if (now < deadline) {
900
+ return fail('DEADLINE_NOT_REACHED', `Task ${detail.taskId} is still live until ${new Date(Number(deadline) * 1000).toISOString()} — claimTimeout reverts before then.`);
901
+ }
902
+ if (!confirm) {
903
+ const quote = createQuote('timeout', { taskId: detail.taskId });
904
+ return ok({
905
+ quote: {
906
+ action: 'claimTimeout',
907
+ taskId: detail.taskId,
908
+ status: statusName(status),
909
+ deadlinePassed: new Date(Number(deadline) * 1000).toISOString(),
910
+ refund: refundAmount(detail),
911
+ refundTo: payFrom,
912
+ settlement: s.mode,
913
+ // DISPUTE_WINDOW is enforced on-chain and disputedAt is not exposed
914
+ // here, so a disputed task can still revert after this quote.
915
+ ...(status === 6 ? { note: 'Task is Disputed — this only succeeds once the on-chain DISPUTE_WINDOW has elapsed since the dispute was raised, otherwise it reverts with DisputeWindowActive.' } : {}),
916
+ quoteId: quote.quoteId,
917
+ },
918
+ next: `Re-call claim_timeout with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to send it.`,
919
+ });
920
+ }
921
+ if (!quoteId || !consumeQuote(quoteId, 'timeout')) {
922
+ return fail('QUOTE_REQUIRED', 'Get a quote first (call without confirm), then re-call with confirm=true and the returned quoteId (quotes are single-use and expire after 10 minutes)');
923
+ }
924
+ try {
925
+ const record = {
926
+ idempotencyKey,
927
+ kind: 'timeout',
928
+ stage: 'created',
929
+ settlement: s.mode,
930
+ taskId: Number(detail.taskId),
931
+ taskHash: detail.taskHash,
932
+ amountWei: detail.amount,
933
+ createdAt: new Date().toISOString(),
934
+ updatedAt: new Date().toISOString(),
935
+ };
936
+ putSpend(record);
937
+ const done = await sendRefund(record);
938
+ return ok({ ...done, refunded: refundAmount(detail), hint: 'Escrow returned to the posting wallet.' });
939
+ }
940
+ catch (err) {
941
+ return fail(err.code ?? 'TIMEOUT_CLAIM_FAILED', err.message);
942
+ }
943
+ });
944
+ // ── Executor side (Base) ─────────────────────────────────────────────────
945
+ //
946
+ // accept_task (tools.ts) already assigns you on-chain: the backend awaits
947
+ // marketplaceAssign inside POST /accept. What was missing is everything
948
+ // after: the worker must sign submitEvidence itself (onlyWorker), and the
949
+ // platform worker (backend/agents/worker.js) only carries a 0G signer, so
950
+ // a Base task could be accepted but never delivered. These two tools close
951
+ // that gap for an MCP executor whose wallet is a Privy relay wallet — no
952
+ // local key, gas negotiated by the backend like every other relayed send.
953
+ /** Poll the escrow until the task reads `want`, or give up. */
954
+ async function waitStatus(s, taskId, want, label) {
955
+ let last = -1;
956
+ for (let i = 0; i < 30; i++) {
957
+ last = Number((await loadTask(s, taskId)).status);
958
+ if (last === want)
959
+ return last;
960
+ await new Promise((r) => setTimeout(r, 3000));
961
+ }
962
+ const e = new Error(`task ${taskId} still reads ${statusName(last)} after 90s waiting for ${label} — re-call complete_task to resume from the chain's state`);
963
+ e.code = 'STATE_PENDING';
964
+ throw e;
965
+ }
966
+ server.registerTool('fetch_brief', {
967
+ title: 'Fetch a Task Brief',
968
+ description: "Download a task's brief by rootHash (accept_task returns it). Public briefs come back as text. For a PRIVATE brief pass the wrappedKey accept_task returned: it is decrypted with BLINDMARKET_PRIVATE_KEY, which must be the key whose public half you registered (wallet_status shows it as executorPublicKey).",
969
+ inputSchema: {
970
+ rootHash: z.string().min(32).max(80).describe('rootHash from accept_task or list_open_tasks'),
971
+ wrappedKey: z.string().optional().describe('ECIES-wrapped AES key from accept_task (hex, no 0x). Required for private briefs.'),
972
+ },
973
+ annotations: { readOnlyHint: true, openWorldHint: true },
974
+ }, async ({ rootHash, wrappedKey }) => {
975
+ try {
976
+ // Buffer.from(x, 'hex') stops silently at the first bad char, so a
977
+ // pasted "0x…" or a truncated blob would otherwise surface as
978
+ // WRONG_KEY after a storage round-trip. Validate first.
979
+ // ECIES blob = 65-byte ephemeral pubkey + 12 IV + 16 tag + ciphertext.
980
+ const keyHex = wrappedKey?.replace(/^0x/i, '');
981
+ if (keyHex !== undefined && (!/^[0-9a-fA-F]+$/.test(keyHex) || keyHex.length % 2 !== 0 || keyHex.length < (65 + 12 + 16 + 1) * 2)) {
982
+ return fail('INVALID_WRAPPED_KEY', 'wrappedKey must be the full hex ECIES blob from accept_task (even length, at least 94 bytes). Pass it exactly as returned.');
983
+ }
984
+ const { blob } = await api('GET', `/api/v1/storage/${encodeURIComponent(rootHash)}`);
985
+ const buf = Buffer.from(blob, 'base64');
986
+ if (keyHex !== undefined) {
987
+ if (!walletCtx) {
988
+ return fail('NO_WALLET', 'A private brief is decrypted with BLINDMARKET_PRIVATE_KEY — set it to the key whose public half you registered as executor.');
989
+ }
990
+ let aesKey;
991
+ try {
992
+ aesKey = eciesDecrypt(Buffer.from(keyHex, 'hex'), walletCtx.wallet.privateKey);
993
+ }
994
+ catch (e) {
995
+ return fail('WRONG_KEY', `Could not unwrap the brief key with the local wallet ${walletCtx.wallet.address}: ${e.message}. The poster wrapped it to the pubkey on your executor registration — wallet_status shows the pubkey this process derives; they must match.`);
996
+ }
997
+ let brief;
998
+ try {
999
+ brief = aesDecrypt(buf, aesKey).toString('utf8');
1000
+ }
1001
+ catch (e) {
1002
+ // The key unwrapped fine, so the BLOB is the problem: a public
1003
+ // (plaintext) brief passed with a wrappedKey, or the wrong rootHash.
1004
+ return fail('BRIEF_DECRYPT_FAILED', `The wrapped key unwrapped, but the blob at ${rootHash} did not decrypt with it: ${e.message}. If the task is public, call fetch_brief without wrappedKey; otherwise check the rootHash came from the same accept_task response.`);
1005
+ }
1006
+ return ok({ rootHash, bytes: buf.length, brief, decrypted: true });
1007
+ }
1008
+ const text = buf.toString('utf8');
1009
+ if (text.includes('�')) {
1010
+ return fail('ENCRYPTED_BRIEF', 'This brief is encrypted (private task). Re-call fetch_brief with the wrappedKey from accept_task.');
1011
+ }
1012
+ return ok({ rootHash, bytes: buf.length, brief: text });
1013
+ }
1014
+ catch (err) {
1015
+ return fail(err.code ?? 'BRIEF_FETCH_FAILED', err.message);
1016
+ }
1017
+ });
1018
+ server.registerTool('complete_task', {
1019
+ title: 'Deliver a Task Result and Settle',
1020
+ description: 'Executor side, after accept_task: submits your result, sends submitEvidence from YOUR wallet (Base: through the backend relay, gas in USDC; 0G: signed locally with BLINDMARKET_PRIVATE_KEY), then asks the backend to verify and release the escrow to you. This is the ONLY delivery tool — there is no separate submit step. If an earlier call died between submitting and broadcasting (task stuck "submitted" off-chain, Assigned on-chain), re-calling heals it via the /rebroadcast endpoint. If the last verification FAILED, calling this again resubmits — the contract allows up to 3 attempts before the deadline. Safe to re-call: it resumes from whatever stage the escrow shows.',
1021
+ inputSchema: {
1022
+ task: z.string().regex(/^0x[0-9a-fA-F]{64}$/).describe('The 0x task hash — A2A tasks are addressed by hash, not by numeric id'),
1023
+ output: z.string().min(1).max(200_000).describe('Your result. Verification judges this text (auto mode scores it against the poster\'s criteria).'),
1024
+ },
1025
+ annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
1026
+ }, async ({ task, output }) => {
1027
+ const f = await requireFunding();
1028
+ if ('error' in f)
1029
+ return f.error;
1030
+ const { s, payFrom } = f;
1031
+ // requireFunding returns an error on 0G without a wallet, so walletCtx is
1032
+ // non-null whenever s.mode is '0g' from here on.
1033
+ let detail;
1034
+ try {
1035
+ detail = await loadTask(s, task);
1036
+ }
1037
+ catch (err) {
1038
+ return fail(err.code ?? 'TASK_LOOKUP_FAILED', err.message);
1039
+ }
1040
+ // 0 Funded · 1 Assigned · 2 Submitted · 3 Verified · 4 Completed · 5 Cancelled · 6 Disputed
1041
+ let status = Number(detail.status);
1042
+ if (status === 0) {
1043
+ return fail('NOT_ASSIGNED', `Task ${detail.taskId} is still Funded — call accept_task("${task}") first; the backend assigns you on-chain as part of accept.`);
1044
+ }
1045
+ // Verified(3) = the last round FAILED and the escrow is still locked. The
1046
+ // contract lets the worker submitEvidence again from here, up to
1047
+ // MAX_SUBMISSION_ATTEMPTS (3) and before the deadline. The backend's
1048
+ // /submit enforces all three on-chain gates; check them here too so the
1049
+ // caller gets the reason instead of a relayed revert.
1050
+ const isRetry = status === 3;
1051
+ // The contract reverts DeadlineReached from Assigned as well as from a
1052
+ // retry. With an explicit gasLimit ethers skips estimateGas, so a
1053
+ // predictable revert would be mined and charged — refuse it here.
1054
+ if ((status === 1 || isRetry) && BigInt(Math.floor(Date.now() / 1000)) >= BigInt(detail.deadline)) {
1055
+ return fail('DEADLINE_REACHED', `Task ${detail.taskId} is past its deadline — no further submissions are accepted on-chain. The poster can reclaim the escrow with claim_timeout.`);
1056
+ }
1057
+ if (isRetry) {
1058
+ const attempts = detail.submissionAttempts ?? 0;
1059
+ if (attempts >= 3) {
1060
+ return fail('MAX_ATTEMPTS_REACHED', `Task ${detail.taskId} has used all 3 submission attempts (${attempts}/3). The escrow stays locked until the poster reclaims it after the deadline.`);
1061
+ }
1062
+ }
1063
+ if (status >= 4) {
1064
+ return ok({ taskId: detail.taskId, taskHash: detail.taskHash, onChainStatus: statusName(status), hint: 'Already settled — nothing to do.' });
1065
+ }
1066
+ let submitTxHash;
1067
+ let gas;
1068
+ let healed = false;
1069
+ try {
1070
+ if (status === 1 || isRetry) {
1071
+ let sub;
1072
+ try {
1073
+ sub = await api('POST', `/api/v1/a2a/tasks/${task}/submit`, { resultData: { output } });
1074
+ }
1075
+ catch (err) {
1076
+ // Stranded 'submitted': /submit flips the off-chain state when the
1077
+ // tx is BUILT, so an earlier call that died before broadcasting
1078
+ // leaves the chain at Assigned(1) while /submit refuses a rebuild.
1079
+ // /rebroadcast rebuilds the same tx from the stored result — the
1080
+ // FIRST output is what gets delivered, not this call's.
1081
+ if (err.code !== 'INVALID_STATE' || status !== 1)
1082
+ throw err;
1083
+ sub = await api('POST', `/api/v1/a2a/tasks/${task}/rebroadcast`);
1084
+ healed = true;
1085
+ }
1086
+ const tx = sub.unsignedSubmitEvidence;
1087
+ await verifyTarget(s, tx.to, 'submitEvidence');
1088
+ if (s.mode === 'base') {
1089
+ const sent = await relaySend(s, { to: tx.to, data: tx.data });
1090
+ submitTxHash = sent.hash;
1091
+ gas = sent.gas;
1092
+ await waitRelayed(s, sent.hash, sent.isUserOp);
1093
+ }
1094
+ else {
1095
+ // submitEvidence is onlyWorker: the backend built the tx for the
1096
+ // API key's wallet (tx.from). If BLINDMARKET_PRIVATE_KEY is a
1097
+ // different wallet the tx reverts on-chain — with gasLimit set,
1098
+ // that revert is mined and paid for. Refuse before sending.
1099
+ if (tx.from && tx.from.toLowerCase() !== walletCtx.wallet.address.toLowerCase()) {
1100
+ return fail('WALLET_MISMATCH', `The backend assigned this task to ${tx.from} (the wallet behind BLINDMARKET_API_KEY), but BLINDMARKET_PRIVATE_KEY is ${walletCtx.wallet.address}. submitEvidence is worker-only — set the private key of ${tx.from}.`);
1101
+ }
1102
+ // 0G: sign locally, exactly as fundAndIndex does for createTask.
1103
+ // The backend pins chainId onto the tx; ethers refuses to send it
1104
+ // if this wallet's provider is on a different network — the guard
1105
+ // against a Base tx reaching a 0G signer.
1106
+ const tx0g = await walletCtx.wallet.sendTransaction({
1107
+ to: tx.to, data: tx.data, gasLimit: GAS_LIMIT,
1108
+ ...(tx.chainId !== undefined ? { chainId: tx.chainId } : {}),
1109
+ });
1110
+ submitTxHash = tx0g.hash;
1111
+ await tx0g.wait();
1112
+ }
1113
+ status = await waitStatus(s, detail.taskId, 2, 'Submitted');
1114
+ }
1115
+ // Backend runs the verification for this task's mode and, on a pass,
1116
+ // sends completeVerification from the marketplace signer — which is
1117
+ // what releases the USDC to payFrom. The call awaits that tx.
1118
+ const fin = await api('POST', `/api/v1/a2a/tasks/${task}/finalize`);
1119
+ const after = await loadTask(s, detail.taskId);
1120
+ const done = Number(after.status) === 4;
1121
+ return ok({
1122
+ taskId: detail.taskId,
1123
+ taskHash: detail.taskHash,
1124
+ submitTxHash,
1125
+ gas,
1126
+ ...(healed ? { rebroadcast: true, note: 'An earlier submission for this task never reached the chain; its stored result was re-broadcast. The output passed to THIS call was not used.' } : {}),
1127
+ verification: fin.verificationResult ?? null,
1128
+ backendStatus: fin.status,
1129
+ onChainStatus: statusName(Number(after.status)),
1130
+ paidTo: done ? payFrom : undefined,
1131
+ submissionAttempts: after.submissionAttempts,
1132
+ hint: done
1133
+ ? `Escrow released: ${formatUnits(BigInt(after.amount), after.decimals ?? 6)} ${s.mode === 'base' ? 'USDC' : '0G'} minus the marketplace fee is now in ${payFrom}.`
1134
+ : fin.awaitingPosterApproval
1135
+ ? 'Manual-verification task: the poster must approve via verify_task before the escrow releases.'
1136
+ : `Verification did not pass (${(fin.verificationResult?.reasons ?? []).join('; ') || 'no reasons given'}). ` +
1137
+ ((after.submissionAttempts ?? 0) < 3
1138
+ ? `Revise and call complete_task again — ${3 - (after.submissionAttempts ?? 0)} attempt(s) left before the deadline.`
1139
+ : 'No attempts left; the escrow stays locked until the poster reclaims it.'),
1140
+ });
1141
+ }
1142
+ catch (err) {
1143
+ return fail(err.code ?? 'COMPLETE_FAILED', err.message);
1144
+ }
1145
+ });
1146
+ return { settlement };
362
1147
  }
363
1148
  //# sourceMappingURL=rent.js.map