@blindmarket/mcp-server 0.3.4 → 0.3.5

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