@blindmarket/mcp-server 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/rent.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
- import { Contract, Interface, JsonRpcProvider, formatUnits, parseUnits } from 'ethers';
2
+ import { Contract, Interface, JsonRpcProvider, formatUnits, keccak256, parseUnits, toUtf8Bytes } from 'ethers';
3
3
  import { aesDecrypt, aesEncrypt, derivePublicKeyHex, eciesDecrypt, eciesEncrypt, generateAesKey, sha256Hex } from './crypto.js';
4
- import { createQuote, consumeQuote, getSpend, putSpend, updateSpend } from './state.js';
4
+ import { createQuote, consumeQuote, getSpend, putSpend, updateSpend, } from './state.js';
5
5
  import { createSettlementResolver, isErc20Settlement, rpcFor, rpcEnvName, } from './settlement.js';
6
6
  // Read-only view of BlindEscrow.getTask, for reading a task's state directly
7
7
  // from the chain that holds it. Field order matches contracts/BlindEscrow.sol;
@@ -28,6 +28,18 @@ const ESCROW_READ_ABI = [
28
28
  * machine, so retries resume instead of double-funding.
29
29
  */
30
30
  const ZERO_TOKEN = '0x0000000000000000000000000000000000000000';
31
+ // The only calls the backend builds for this process to sign or relay:
32
+ // backend/src/services/escrow.ts (createTask, cancelTask, claimTimeout,
33
+ // submitEvidence). verifyTarget checks where a transaction goes, but the
34
+ // escrow address comes from the same backend, so a hostile answer could name
35
+ // the token as the escrow and hand over an approve; the call, its arguments
36
+ // and its value are what bound it (security audit run 1, C41).
37
+ const ESCROW_CALLS = new Interface([
38
+ 'function createTask(bytes32 taskHash, address token, uint256 amount, string category, string locationZone, uint256 duration)',
39
+ 'function submitEvidence(uint256 taskId, bytes32 evidenceHash)',
40
+ 'function cancelTask(uint256 taskId)',
41
+ 'function claimTimeout(uint256 taskId)',
42
+ ]);
31
43
  const GAS_LIMIT = 1000000n; // matches the canonical rent script
32
44
  // Auto-verify releases the payment, so the bar can't be "one character" — but
33
45
  // 40 made a correct 30-character URL unpayable. 20 is the platform floor
@@ -45,6 +57,66 @@ function ok(data) {
45
57
  function fail(code, message) {
46
58
  return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: { code, message } }) }] };
47
59
  }
60
+ /**
61
+ * Throw TX_MISMATCH, before anything is signed or relayed, unless the
62
+ * backend's unsigned `tx` is exactly `fn` (canonically encoded) with
63
+ * arguments `argsOk` accepts, and names no value other than `value`. Only
64
+ * `to` and `data` are ever forwarded, so a value the backend names is never
65
+ * sent; a wrong one still means the transaction is not the one asked for.
66
+ */
67
+ function assertEscrowCall(tx, fn, argsOk, what, value = 0n) {
68
+ const data = typeof tx.data === 'string' ? tx.data.toLowerCase() : '';
69
+ let ok = false;
70
+ try {
71
+ const args = ESCROW_CALLS.decodeFunctionData(fn, data);
72
+ ok = ESCROW_CALLS.encodeFunctionData(fn, args).toLowerCase() === data && argsOk(args);
73
+ }
74
+ catch { /* another function, or not ABI data at all */ }
75
+ if (ok && tx.value != null) {
76
+ try {
77
+ ok = BigInt(tx.value) === value;
78
+ }
79
+ catch {
80
+ ok = false;
81
+ }
82
+ }
83
+ if (!ok) {
84
+ const e = new Error(`backend built ${what} that is not the ${fn} call this spend asked for (another function, other arguments, or a value). Nothing was sent.`);
85
+ e.code = 'TX_MISMATCH';
86
+ throw e;
87
+ }
88
+ }
89
+ const QUOTE_REQUIRED_MESSAGE = '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)';
90
+ /** Why a confirm was refused against its quote. Nothing was uploaded,
91
+ * approved or sent by then. `why` replaces the generic list of what changed. */
92
+ function quoteRefused(check, tool, why) {
93
+ if (check.code === 'QUOTE_REQUIRED')
94
+ return fail('QUOTE_REQUIRED', QUOTE_REQUIRED_MESSAGE);
95
+ return fail('QUOTE_MISMATCH', `Nothing was sent: ${why ?? `this confirm would spend something other than quote ${check.quote.quoteId} (changed: ${check.changed.join(', ')})`}. ` +
96
+ `That quote is now used up. Call ${tool} again without confirm to get a new quote, check it, then confirm with the new quoteId.`);
97
+ }
98
+ /** The chain, escrow, token and wallet a spend moves money on, for its quote binding. */
99
+ function settlementFields(s, payFrom) {
100
+ return {
101
+ chain: s.mode,
102
+ chainId: s.chainId ?? null,
103
+ escrow: s.escrowAddress ? s.escrowAddress.toLowerCase() : null,
104
+ token: isErc20Settlement(s) ? s.token.address.toLowerCase() : ZERO_TOKEN,
105
+ payFrom: payFrom.toLowerCase(),
106
+ };
107
+ }
108
+ /** How the network a spend was recorded on differs from the one `s` settles
109
+ * on now, or null. A chain key keeps its name when the backend moves it to
110
+ * another network (Arc Testnet 5042002 and Arc mainnet 5042 are both 'arc'),
111
+ * so the record's chain id decides. A record written before records kept one
112
+ * cannot be checked. */
113
+ function networkChange(record, s) {
114
+ if (s.chainId === undefined || record.chainId === s.chainId)
115
+ return null;
116
+ return record.chainId === undefined
117
+ ? `was recorded without a chain id, so it cannot be checked against ${s.mode} on chain ${s.chainId}`
118
+ : `started on ${s.mode} chain ${record.chainId}, but the backend's ${s.mode} is chain ${s.chainId} now`;
119
+ }
48
120
  export function registerRentTools(server, cfg, walletCtx) {
49
121
  /** Every authenticated call funnels through api(), so this is the one place
50
122
  * the missing-key case needs handling. Without it the caller gets the
@@ -253,7 +325,7 @@ export function registerRentTools(server, cfg, walletCtx) {
253
325
  /** Wait for a relayed tx to land. A plain hash can be polled for its receipt;
254
326
  * a user-op hash cannot (getTransactionReceipt is always null for it), so
255
327
  * that case returns at once and the caller confirms by on-chain STATE —
256
- * see ensureAllowance and waitCancelled. */
328
+ * see ensureAllowance and waitLanded. */
257
329
  async function waitRelayed(s, hash, isUserOp) {
258
330
  if (isUserOp)
259
331
  return;
@@ -345,6 +417,14 @@ export function registerRentTools(server, cfg, walletCtx) {
345
417
  e.code = 'SETTLEMENT_CHANGED';
346
418
  throw e;
347
419
  }
420
+ // Nor on another network under the same key: the spend was quoted and
421
+ // confirmed for the one it started on.
422
+ const moved = networkChange(record, s);
423
+ if (moved) {
424
+ const e = new Error(`spend ${record.idempotencyKey} ${moved}. Nothing was funded yet (stage ${record.stage}): start a new spend with a new idempotencyKey.`);
425
+ e.code = 'SETTLEMENT_CHANGED';
426
+ throw e;
427
+ }
348
428
  const refused = await notPostingChain(s);
349
429
  if (refused)
350
430
  throw refused;
@@ -374,6 +454,15 @@ export function registerRentTools(server, cfg, walletCtx) {
374
454
  throw e;
375
455
  }
376
456
  await verifyTarget(s, unsignedTx.to, 'createTask');
457
+ // And it must be this spend's createTask: its task hash, token, amount
458
+ // and duration (the amount is what was approved above, or the value sent below).
459
+ const amount = BigInt(record.amountWei);
460
+ const token = isErc20Settlement(s) ? s.token.address : ZERO_TOKEN;
461
+ assertEscrowCall(unsignedTx, 'createTask', (a) => String(a[0]).toLowerCase() === String(record.taskHash).toLowerCase()
462
+ && String(a[1]).toLowerCase() === token.toLowerCase()
463
+ && a[2] === amount
464
+ && a[4] === 'global'
465
+ && a[5] === BigInt(record.durationSecs ?? 3600), 'createTask', isErc20Settlement(s) ? 0n : amount);
377
466
  if (isErc20Settlement(s)) {
378
467
  const { hash, isUserOp, gas } = await sendErc20(s, { to: unsignedTx.to, data: unsignedTx.data }, nonce);
379
468
  // Persist BEFORE waiting, same reasoning as the 0G branch below.
@@ -442,7 +531,7 @@ export function registerRentTools(server, cfg, walletCtx) {
442
531
  // ── rent_service ──────────────────────────────────────────────────────────
443
532
  server.registerTool('rent_service', {
444
533
  title: 'Rent an Agent Service',
445
- 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 paid on the backend\'s posting chain: USDC on Arc signed by the local wallet (BLINDMARKET_PRIVATE_KEY, which must own BLINDMARKET_API_KEY; gas is also USDC), USDC on Base through the backend relay (no private key needed), or native 0G from the local wallet. wallet_status shows which. 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).',
534
+ 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 paid on the backend\'s posting chain: USDC on Arc signed by the local wallet (BLINDMARKET_PRIVATE_KEY, which must own BLINDMARKET_API_KEY; gas is also USDC), USDC on Base through the backend relay (no private key needed), or native 0G from the local wallet. wallet_status shows which. TWO-STEP: first call returns a price quote + quoteId; re-call with the SAME arguments plus confirm=true and that quoteId to actually spend (a confirm that differs from its quote, or a listing re-priced since the quote, is refused with QUOTE_MISMATCH and nothing is sent). Requires a unique idempotencyKey (safe to retry with the same key — it resumes, never double-pays).',
446
535
  inputSchema: {
447
536
  serviceId: z.number().int().positive().describe('Service id from browse_services / get_service'),
448
537
  prompt: z.string().min(1).max(100_000).describe('What you want the agent to do'),
@@ -496,8 +585,22 @@ export function registerRentTools(server, cfg, walletCtx) {
496
585
  return fail('PRICE_UNITS_SUSPECT', `service ${serviceId} lists price_raw=${priceRaw} which is ${formatUnits(priceRaw, s.decimals)} ${s.symbol} — this looks like an 18-decimal 0G price on a ${s.symbol} chain. Not sending. Re-list the service in ${s.symbol} base units.`);
497
586
  }
498
587
  const price = formatUnits(priceRaw, s.decimals);
588
+ // Exactly what this call would spend, derived after every lookup above
589
+ // and before anything is uploaded or sent. The quote stores it and the
590
+ // confirm must match it: a provider can re-price its listing between
591
+ // the two calls, and the confirm call carries no price of its own.
592
+ const spend = {
593
+ ...settlementFields(s, payFrom),
594
+ idempotencyKey,
595
+ serviceId: String(serviceId),
596
+ listingId: String(service.id),
597
+ agent: String(service.agent_address).toLowerCase(),
598
+ priceRaw: priceRaw.toString(),
599
+ privacy: isPublic ? 'public' : 'private',
600
+ prompt: sha256Hex(Buffer.from(prompt, 'utf8')),
601
+ };
499
602
  if (!confirm) {
500
- const quote = createQuote('rent', { serviceId, price, currency: s.symbol });
603
+ const quote = createQuote('rent', { serviceId, price, currency: s.symbol }, spend);
501
604
  return ok({
502
605
  quote: {
503
606
  service: { id: service.id, name: service.name, agent: service.agent_address },
@@ -512,8 +615,12 @@ export function registerRentTools(server, cfg, walletCtx) {
512
615
  next: `Re-call rent_service with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to execute this spend.`,
513
616
  });
514
617
  }
515
- if (!quoteId || !consumeQuote(quoteId, 'rent')) {
516
- 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)');
618
+ const check = consumeQuote(quoteId, 'rent', spend);
619
+ if (!check.ok) {
620
+ const repriced = check.code === 'QUOTE_MISMATCH' && check.changed.includes('priceRaw')
621
+ ? `the listing's price changed from ${check.quote.summary.price} ${check.quote.summary.currency} to ${price} ${s.symbol} since the quote`
622
+ : undefined;
623
+ return quoteRefused(check, 'rent_service', repriced);
517
624
  }
518
625
  try {
519
626
  // Prepare the brief blob (the canonical script's steps 1-3).
@@ -550,8 +657,10 @@ export function registerRentTools(server, cfg, walletCtx) {
550
657
  verificationMode: 'auto',
551
658
  verificationCriteria: RENTAL_VERIFICATION_CRITERIA,
552
659
  requiredCapabilities: [],
553
- amountWei: String(service.price_raw),
660
+ // The quoted price, which the binding above just matched.
661
+ amountWei: priceRaw.toString(),
554
662
  settlement: s.mode,
663
+ chainId: s.chainId,
555
664
  token: isErc20Settlement(s) ? s.token.address : ZERO_TOKEN,
556
665
  durationSecs: 3600,
557
666
  createdAt: new Date().toISOString(),
@@ -560,7 +669,7 @@ export function registerRentTools(server, cfg, walletCtx) {
560
669
  putSpend(record);
561
670
  const done = await fundAndIndex(record);
562
671
  const polled = await pollPosted(done.taskHash, waitSeconds ?? 45);
563
- return ok({ ...done, ...polled });
672
+ return ok({ ...done, escrowed: { amount: price, amountRaw: priceRaw.toString(), currency: s.symbol }, ...polled });
564
673
  }
565
674
  catch (err) {
566
675
  const code = err.code;
@@ -573,7 +682,7 @@ export function registerRentTools(server, cfg, walletCtx) {
573
682
  // ── post_task ─────────────────────────────────────────────────────────────
574
683
  server.registerTool('post_task', {
575
684
  title: 'Post a Task to the Open Market',
576
- 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 paid on the backend\'s posting chain: USDC on Arc signed by the local wallet (BLINDMARKET_PRIVATE_KEY, which must own BLINDMARKET_API_KEY; gas is also USDC), USDC on Base through the backend relay (no private key needed), or native 0G from the local wallet. wallet_status shows which. TWO-STEP quote/confirm like rent_service; requires a unique idempotencyKey.',
685
+ 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 paid on the backend\'s posting chain: USDC on Arc signed by the local wallet (BLINDMARKET_PRIVATE_KEY, which must own BLINDMARKET_API_KEY; gas is also USDC), USDC on Base through the backend relay (no private key needed), or native 0G from the local wallet. wallet_status shows which. TWO-STEP quote/confirm like rent_service: confirm with the same arguments, or it is refused with QUOTE_MISMATCH; requires a unique idempotencyKey.',
577
686
  inputSchema: {
578
687
  instructions: z.string().min(1).max(100_000).describe('The task brief'),
579
688
  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'),
@@ -619,8 +728,19 @@ export function registerRentTools(server, cfg, walletCtx) {
619
728
  catch {
620
729
  return fail('AMOUNT_INVALID', `"${amountStr}" is not a valid ${s.symbol} amount — at most ${s.decimals} decimal places.`);
621
730
  }
731
+ const durationSecs = durationSeconds ?? 86400;
732
+ // What this call would spend, bound into the quote (see rent_service).
733
+ const spend = {
734
+ ...settlementFields(s, payFrom),
735
+ idempotencyKey,
736
+ amountRaw: amountWei.toString(),
737
+ durationSecs,
738
+ capabilities: JSON.stringify(capabilities ?? []),
739
+ privacy: isPublic ? 'public' : 'private',
740
+ instructions: sha256Hex(Buffer.from(instructions, 'utf8')),
741
+ };
622
742
  if (!confirm) {
623
- const quote = createQuote('post', { amount: amountStr, currency: s.symbol });
743
+ const quote = createQuote('post', { amount: amountStr, currency: s.symbol }, spend);
624
744
  return ok({
625
745
  quote: {
626
746
  escrow: amountStr,
@@ -635,9 +755,9 @@ export function registerRentTools(server, cfg, walletCtx) {
635
755
  next: `Re-call post_task with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to execute this spend.`,
636
756
  });
637
757
  }
638
- if (!quoteId || !consumeQuote(quoteId, 'post')) {
639
- 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)');
640
- }
758
+ const check = consumeQuote(quoteId, 'post', spend);
759
+ if (!check.ok)
760
+ return quoteRefused(check, 'post_task');
641
761
  try {
642
762
  const plaintext = Buffer.from(instructions, 'utf8');
643
763
  let blobB64;
@@ -684,14 +804,21 @@ export function registerRentTools(server, cfg, walletCtx) {
684
804
  requiredCapabilities: capabilities ?? [],
685
805
  amountWei: amountWei.toString(),
686
806
  settlement: s.mode,
807
+ chainId: s.chainId,
687
808
  token: isErc20Settlement(s) ? s.token.address : ZERO_TOKEN,
688
- durationSecs: durationSeconds ?? 86400,
809
+ durationSecs,
689
810
  createdAt: new Date().toISOString(),
690
811
  updatedAt: new Date().toISOString(),
691
812
  };
692
813
  putSpend(record);
693
814
  const done = await fundAndIndex(record);
694
- return ok({ ...done, wrappedTo: wrappedKeys ? Object.keys(wrappedKeys).length : 0, privacy: record.privacy, hint: 'Use poll_task_result to wait for the deliverable.' });
815
+ return ok({
816
+ ...done,
817
+ escrowed: { amount: formatUnits(amountWei, s.decimals), amountRaw: amountWei.toString(), currency: s.symbol },
818
+ wrappedTo: wrappedKeys ? Object.keys(wrappedKeys).length : 0,
819
+ privacy: record.privacy,
820
+ hint: 'Use poll_task_result to wait for the deliverable.',
821
+ });
695
822
  }
696
823
  catch (err) {
697
824
  const code = err.code;
@@ -722,6 +849,11 @@ export function registerRentTools(server, cfg, walletCtx) {
722
849
  // Disputed (6) → claimTimeout, but only once
723
850
  // DISPUTE_WINDOW has elapsed
724
851
  //
852
+ // On an upgraded escrow, claimTimeout on a Submitted task (work delivered
853
+ // before the deadline, never judged) refunds nothing: it sends the task for
854
+ // review and leaves it Disputed (security audit run 1, C18). A failed
855
+ // (Verified) task refunds only after the worker's 3-day appeal window.
856
+ //
725
857
  // So the quote step reads the live status first and refuses a path the
726
858
  // contract would reject, rather than letting the caller burn gas to find out.
727
859
  //
@@ -810,18 +942,30 @@ export function registerRentTools(server, cfg, walletCtx) {
810
942
  function refundAmount(detail) {
811
943
  return formatUnits(BigInt(detail.amount), detail.decimals ?? 18);
812
944
  }
813
- /** The refund has landed when the task reads Cancelled on-chain. Checking
945
+ /** What a cancel_task / claim_timeout confirm may send, for its quote binding. */
946
+ function refundFields(s, payFrom, idempotencyKey, detail) {
947
+ return {
948
+ ...settlementFields(s, payFrom),
949
+ idempotencyKey,
950
+ taskId: String(detail.taskId),
951
+ taskHash: String(detail.taskHash).toLowerCase(),
952
+ amountRaw: String(detail.amount),
953
+ };
954
+ }
955
+ /** The refund has landed when the task reads one of `settled` on-chain:
956
+ * Cancelled, or for a claim on a Submitted task also Disputed (sent for
957
+ * review; an escrow from before that change refunds it instead). Checking
814
958
  * state rather than a receipt is what makes a relayed user-op decidable
815
959
  * (there is no receipt to poll), and it is a cheap truth check for the
816
- * local-signing path too. */
817
- async function waitCancelled(s, taskId) {
960
+ * local-signing path too. Resolves to the status it read. */
961
+ async function waitLanded(s, taskId, settled) {
818
962
  for (let i = 0; i < 30; i++) {
819
963
  const detail = await loadTask(s, String(taskId)).catch(() => null);
820
- if (detail && Number(detail.status) === 5)
821
- return;
964
+ if (detail && settled.includes(Number(detail.status)))
965
+ return Number(detail.status);
822
966
  await new Promise((r) => setTimeout(r, 3000));
823
967
  }
824
- const e = new Error(`task ${taskId} still not Cancelled on-chain after 90s — retry with the same idempotencyKey to keep waiting`);
968
+ const e = new Error(`task ${taskId} still not ${settled.map(statusName).join(' or ')} on-chain after 90s — retry with the same idempotencyKey to keep waiting`);
825
969
  e.code = 'REFUND_PENDING';
826
970
  throw e;
827
971
  }
@@ -839,6 +983,18 @@ export function registerRentTools(server, cfg, walletCtx) {
839
983
  e.code = 'SETTLEMENT_CHANGED';
840
984
  throw e;
841
985
  }
986
+ // Nor on another network under the same key, where the task id names
987
+ // another task. Only a refund that would still sign needs a chain id on
988
+ // record; a sent one waits for its own transaction.
989
+ const moved = networkChange(record, s);
990
+ if (moved && (record.stage === 'created' || record.chainId !== undefined)) {
991
+ const e = new Error(`refund ${record.idempotencyKey} ${moved}. ` +
992
+ (record.stage === 'created'
993
+ ? 'Nothing was sent: quote the refund again with a new idempotencyKey.'
994
+ : `Its transaction ${record.txHash} was sent on chain ${record.chainId}.`));
995
+ e.code = 'SETTLEMENT_CHANGED';
996
+ throw e;
997
+ }
842
998
  if (record.stage === 'created') {
843
999
  const route = record.kind === 'cancel' ? 'cancel' : 'timeout';
844
1000
  // Task ids repeat across chains: name this one (the backend knows
@@ -849,6 +1005,8 @@ export function registerRentTools(server, cfg, walletCtx) {
849
1005
  // broadcasts on, stop here — relaying a 0G refund onto another chain
850
1006
  // lands on an address with no escrow and burns the gas.
851
1007
  await verifyTarget(s, unsignedTx.to, `${route}Task`);
1008
+ // And exactly this refund, for this task, with no value.
1009
+ assertEscrowCall(unsignedTx, record.kind === 'cancel' ? 'cancelTask' : 'claimTimeout', (a) => a[0] === BigInt(taskId), `${route}Task`);
852
1010
  if (isErc20Settlement(s)) {
853
1011
  const { hash, isUserOp, gas } = await sendErc20(s, { to: unsignedTx.to, data: unsignedTx.data });
854
1012
  // Persist BEFORE waiting, same reasoning as the 0G branch below.
@@ -881,9 +1039,12 @@ export function registerRentTools(server, cfg, walletCtx) {
881
1039
  else {
882
1040
  throw new Error(`Spend record ${record.idempotencyKey} is at stage '${record.stage}' with no txHash — cannot resume safely`);
883
1041
  }
884
- await waitCancelled(s, taskId);
885
- updateSpend(record.idempotencyKey, { stage: 'confirmed' });
886
- return { taskId, txHash: txHash, gas: record.gas, listingClosed: await confirmRefund(s, taskId, txHash, record.isUserOp ?? false) };
1042
+ const landed = await waitLanded(s, taskId, record.kind === 'timeout' && record.fromStatus === 2 ? [5, 6] : [5]);
1043
+ const outcome = landed === 6 ? 'escalate' : 'refund';
1044
+ updateSpend(record.idempotencyKey, { stage: 'confirmed', outcome });
1045
+ // Sent for review: the task stays live and there is no refund to confirm.
1046
+ const listingClosed = outcome === 'escalate' ? false : await confirmRefund(s, taskId, txHash, record.isUserOp ?? false);
1047
+ return { taskId, txHash: txHash, gas: record.gas, listingClosed, outcome };
887
1048
  }
888
1049
  /** Tell the backend the refund landed (POST /tasks/:id/confirm-tx): it
889
1050
  * checks the receipt and takes the task off the market, which otherwise
@@ -919,7 +1080,10 @@ export function registerRentTools(server, cfg, walletCtx) {
919
1080
  resumed: true,
920
1081
  taskId: existing.taskId,
921
1082
  txHash: existing.txHash,
922
- hint: 'Already refunded — this idempotencyKey completed earlier.',
1083
+ ...(existing.outcome ? { outcome: existing.outcome } : {}),
1084
+ hint: existing.outcome === 'escalate'
1085
+ ? 'Already sent for review — this idempotencyKey completed earlier. Nothing was refunded.'
1086
+ : 'Already refunded — this idempotencyKey completed earlier.',
923
1087
  });
924
1088
  }
925
1089
  try {
@@ -961,8 +1125,11 @@ export function registerRentTools(server, cfg, walletCtx) {
961
1125
  : ' The escrow is already settled — nothing to reclaim.';
962
1126
  return fail('WRONG_REFUND_PATH', `Task ${detail.taskId} is ${statusName(status)}, and cancelTask only accepts Funded tasks.${alt}`);
963
1127
  }
1128
+ // The task (and its escrow) this refund is for, bound into the quote: a
1129
+ // confirm naming another task is refused rather than refunding it.
1130
+ const spend = refundFields(s, payFrom, idempotencyKey, detail);
964
1131
  if (!confirm) {
965
- const quote = createQuote('cancel', { taskId: detail.taskId });
1132
+ const quote = createQuote('cancel', { taskId: detail.taskId }, spend);
966
1133
  return ok({
967
1134
  quote: {
968
1135
  action: 'cancelTask',
@@ -976,15 +1143,16 @@ export function registerRentTools(server, cfg, walletCtx) {
976
1143
  next: `Re-call cancel_task with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to send it.`,
977
1144
  });
978
1145
  }
979
- if (!quoteId || !consumeQuote(quoteId, 'cancel')) {
980
- 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)');
981
- }
1146
+ const check = consumeQuote(quoteId, 'cancel', spend);
1147
+ if (!check.ok)
1148
+ return quoteRefused(check, 'cancel_task');
982
1149
  try {
983
1150
  const record = {
984
1151
  idempotencyKey,
985
1152
  kind: 'cancel',
986
1153
  stage: 'created',
987
1154
  settlement: s.mode,
1155
+ chainId: s.chainId,
988
1156
  taskId: Number(detail.taskId),
989
1157
  taskHash: detail.taskHash,
990
1158
  amountWei: detail.amount,
@@ -1001,7 +1169,7 @@ export function registerRentTools(server, cfg, walletCtx) {
1001
1169
  });
1002
1170
  server.registerTool('claim_timeout', {
1003
1171
  title: 'Reclaim Escrow After the Deadline',
1004
- 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.',
1172
+ description: 'Reclaim the escrow on a task that WAS assigned but never completed, once its deadline has passed (status Assigned, or Verified-failed once the worker\'s 3-day appeal window has passed). On a Submitted task (work delivered before the deadline, never judged) it sends the task for review instead and refunds nothing: an admin rules, and with no ruling within 14 days the worker is paid; the result says outcome "escalate". For a task no worker ever picked up, use cancel_task instead — it needs no deadline. TWO-STEP quote/confirm; requires a unique idempotencyKey.',
1005
1173
  inputSchema: {
1006
1174
  task: z.string().min(1).describe('Task id (e.g. "51") or the 0x task hash returned by post_task'),
1007
1175
  idempotencyKey: z.string().min(8).max(128).describe('Unique key for this refund — reuse it on retries'),
@@ -1036,8 +1204,9 @@ export function registerRentTools(server, cfg, walletCtx) {
1036
1204
  if (now < deadline) {
1037
1205
  return fail('DEADLINE_NOT_REACHED', `Task ${detail.taskId} is still live until ${new Date(Number(deadline) * 1000).toISOString()} — claimTimeout reverts before then.`);
1038
1206
  }
1207
+ const spend = refundFields(s, payFrom, idempotencyKey, detail);
1039
1208
  if (!confirm) {
1040
- const quote = createQuote('timeout', { taskId: detail.taskId });
1209
+ const quote = createQuote('timeout', { taskId: detail.taskId }, spend);
1041
1210
  return ok({
1042
1211
  quote: {
1043
1212
  action: 'claimTimeout',
@@ -1049,22 +1218,26 @@ export function registerRentTools(server, cfg, walletCtx) {
1049
1218
  settlement: s.mode,
1050
1219
  // DISPUTE_WINDOW is enforced on-chain and disputedAt is not exposed
1051
1220
  // here, so a disputed task can still revert after this quote.
1052
- ...(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.' } : {}),
1221
+ ...(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. Delivered work that was sent for review never returns to you by timeout.' } : {}),
1222
+ ...(status === 2 ? { note: 'The work was delivered before the deadline and never judged. The escrow sends it for review instead of refunding you: an admin rules, and with no ruling within 14 days the worker is paid. (An escrow from before that change refunds it.)' } : {}),
1223
+ ...(status === 3 ? { note: "The work failed verification. This reverts with AppealWindowActive until the worker's 3-day appeal window after the verdict has passed." } : {}),
1053
1224
  quoteId: quote.quoteId,
1054
1225
  },
1055
1226
  next: `Re-call claim_timeout with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to send it.`,
1056
1227
  });
1057
1228
  }
1058
- if (!quoteId || !consumeQuote(quoteId, 'timeout')) {
1059
- 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)');
1060
- }
1229
+ const check = consumeQuote(quoteId, 'timeout', spend);
1230
+ if (!check.ok)
1231
+ return quoteRefused(check, 'claim_timeout');
1061
1232
  try {
1062
1233
  const record = {
1063
1234
  idempotencyKey,
1064
1235
  kind: 'timeout',
1065
1236
  stage: 'created',
1066
1237
  settlement: s.mode,
1238
+ chainId: s.chainId,
1067
1239
  taskId: Number(detail.taskId),
1240
+ fromStatus: status,
1068
1241
  taskHash: detail.taskHash,
1069
1242
  amountWei: detail.amount,
1070
1243
  createdAt: new Date().toISOString(),
@@ -1072,6 +1245,9 @@ export function registerRentTools(server, cfg, walletCtx) {
1072
1245
  };
1073
1246
  putSpend(record);
1074
1247
  const done = await sendRefund(record);
1248
+ if (done.outcome === 'escalate') {
1249
+ return ok({ ...done, hint: 'Sent for review: nothing was refunded. An admin rules on the delivered work; with no ruling within 14 days the worker is paid.' });
1250
+ }
1075
1251
  return ok({ ...done, refunded: refundAmount(detail), hint: 'Escrow returned to the posting wallet.' });
1076
1252
  }
1077
1253
  catch (err) {
@@ -1104,7 +1280,7 @@ export function registerRentTools(server, cfg, walletCtx) {
1104
1280
  title: 'Fetch a Task Brief',
1105
1281
  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).",
1106
1282
  inputSchema: {
1107
- rootHash: z.string().min(32).max(80).describe('rootHash from accept_task or list_open_tasks'),
1283
+ rootHash: z.string().min(32).max(80).describe('rootHash from accept_task (browse_a2a_tasks also shows it for a public task)'),
1108
1284
  wrappedKey: z.string().optional().describe('ECIES-wrapped AES key from accept_task (hex, no 0x). Required for private briefs.'),
1109
1285
  },
1110
1286
  annotations: { readOnlyHint: true, openWorldHint: true },
@@ -1222,6 +1398,17 @@ export function registerRentTools(server, cfg, walletCtx) {
1222
1398
  }
1223
1399
  const tx = sub.unsignedSubmitEvidence;
1224
1400
  await verifyTarget(s, tx.to, 'submitEvidence');
1401
+ // A zero-value submitEvidence for this task, committing THIS output
1402
+ // (keccak256 of the JSON /submit hashes, backend/src/routes/a2a.ts).
1403
+ // /rebroadcast re-sends the first stored output, so there only the
1404
+ // task is checked.
1405
+ const evidence = keccak256(toUtf8Bytes(JSON.stringify({ output })));
1406
+ assertEscrowCall(tx, 'submitEvidence', (a) => a[0] === BigInt(detail.taskId) && (healed || String(a[1]).toLowerCase() === evidence), 'submitEvidence');
1407
+ // The chain it is signed for is this process's, never the backend's.
1408
+ const pinned = s.chainId ?? walletCtx?.chainId;
1409
+ if (tx.chainId !== undefined && pinned !== undefined && Number(tx.chainId) !== pinned) {
1410
+ return fail('CHAIN_MISMATCH', `The backend built submitEvidence for chain ${tx.chainId}, but this process settles ${s.mode} on chain ${pinned}. Nothing was sent.`);
1411
+ }
1225
1412
  if (s.payment === 'relay-erc20') {
1226
1413
  const sent = await relaySend(s, { to: tx.to, data: tx.data });
1227
1414
  submitTxHash = sent.hash;
@@ -1247,12 +1434,13 @@ export function registerRentTools(server, cfg, walletCtx) {
1247
1434
  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}.`);
1248
1435
  }
1249
1436
  // 0G: sign locally, exactly as fundAndIndex does for createTask.
1250
- // The backend pins chainId onto the tx; ethers refuses to send it
1251
- // if this wallet's provider is on a different network — the guard
1252
- // against a Base tx reaching a 0G signer.
1437
+ // The chain id is pinned from this process's settlement (checked
1438
+ // against the backend's above); ethers refuses to send it if this
1439
+ // wallet's provider is on a different network — the guard against
1440
+ // a Base tx reaching a 0G signer.
1253
1441
  const tx0g = await walletCtx.wallet.sendTransaction({
1254
1442
  to: tx.to, data: tx.data, gasLimit: GAS_LIMIT,
1255
- ...(tx.chainId !== undefined ? { chainId: tx.chainId } : {}),
1443
+ ...(pinned !== undefined ? { chainId: pinned } : {}),
1256
1444
  });
1257
1445
  submitTxHash = tx0g.hash;
1258
1446
  await tx0g.wait();
@@ -1322,6 +1510,34 @@ export function registerRentTools(server, cfg, walletCtx) {
1322
1510
  throw coded('WRONG_RPC', `${envName} serves chain ${served}, not ${chain} (${chainId}). Nothing was paid.`);
1323
1511
  return walletCtx.wallet.connect(provider);
1324
1512
  }
1513
+ /**
1514
+ * The backend cannot find a deploy fee recorded before records kept its
1515
+ * chain id, so it may have been paid on another network. Its receipt on the
1516
+ * fee chain's RPC decides the answer, which never sends the caller back into
1517
+ * a retry that cannot succeed. Found there, the backend has not seen it yet:
1518
+ * the record takes that chain id, and the same key finishes the deploy.
1519
+ */
1520
+ async function unchainedFeeNotFound(idempotencyKey, hash, fee) {
1521
+ let found = null;
1522
+ if (fee) {
1523
+ try {
1524
+ const w = await walletOn(fee.chain, fee.chainId);
1525
+ found = (await w.provider.getTransactionReceipt(hash))?.status === 1;
1526
+ }
1527
+ catch { /* not checkable from here */ }
1528
+ }
1529
+ if (fee && found) {
1530
+ if (fee.chainId !== undefined)
1531
+ updateSpend(idempotencyKey, { chainId: fee.chainId });
1532
+ return fail('DEPLOY_FEE_NOT_FOUND', `The deploy fee ${hash} is on ${fee.chain}, but the backend has not seen it yet. Retry with the same idempotencyKey in a minute; nothing is paid again.`);
1533
+ }
1534
+ if (fee && found === false) {
1535
+ return fail('SETTLEMENT_CHANGED', `The deploy fee ${hash} was recorded without its chain, and it is not on ${fee.chain}${fee.chainId !== undefined ? ` (chain ${fee.chainId})` : ''}, where the backend takes the fee now: it was paid on another network and does not count here. ` +
1536
+ `Nothing was paid again: deploy with a new idempotencyKey to pay on ${fee.chain}.`);
1537
+ }
1538
+ return fail('DEPLOY_FEE_NOT_FOUND', `The backend cannot find the deploy fee ${hash}, which was recorded without its chain, and it could not be checked here. It may have been paid on another network, where it does not count. ` +
1539
+ 'Nothing was paid again: find where it landed before paying with a new idempotencyKey.');
1540
+ }
1325
1541
  server.registerTool('deploy_agent', {
1326
1542
  title: 'Deploy a Hosted Agent',
1327
1543
  description: "Deploy a hosted agent that runs on BlindMarket and takes tasks, owned by the API key's wallet. Deploying costs a fee (1 USDC on Arc on production), paid as one USDC transfer on Arc from the local wallet (BLINDMARKET_PRIVATE_KEY), which must be the API key's owner. The model provider's key is read from this server's environment (OPENAI_API_KEY, ANTHROPIC_API_KEY, GROQ_API_KEY or GEMINI_API_KEY; none for 0g-compute), never passed as an argument. TWO-STEP quote/confirm like post_task; requires a unique idempotencyKey, and a retry with the same key never pays twice.",
@@ -1397,14 +1613,26 @@ export function registerRentTools(server, cfg, walletCtx) {
1397
1613
  return ok({ resumed: true, agentId: existing.agentId, feeTxHash: existing.txHash, hint: 'Already deployed with this idempotencyKey.' });
1398
1614
  }
1399
1615
  if (existing?.stage === 'sent' && existing.txHash) {
1400
- // Paid before: finish the deploy with that payment.
1616
+ // Paid before: finish the deploy with that payment. It counts only on
1617
+ // the network it was paid on, and a chain keeps its key when the
1618
+ // backend moves it to another one, so the chain ids decide.
1619
+ const current = await api('GET', '/api/v1/agents/deploy-fee').catch(() => null);
1620
+ const currentFee = current?.required && current.method === 'transfer' ? current : null;
1621
+ if (existing.chainId !== undefined && currentFee?.chainId !== undefined && currentFee.chainId !== existing.chainId) {
1622
+ return fail('SETTLEMENT_CHANGED', `The deploy fee for ${idempotencyKey} was paid on chain ${existing.chainId} (${existing.txHash}), but the backend takes it on chain ${currentFee.chainId} now, where that payment does not count. ` +
1623
+ `Nothing was paid again: deploy with a new idempotencyKey to pay on chain ${currentFee.chainId}.`);
1624
+ }
1401
1625
  try {
1402
1626
  const agent = await deploy(existing.txHash);
1403
1627
  updateSpend(idempotencyKey, { stage: 'confirmed', agentId: agent.id });
1404
1628
  return ok({ resumed: true, ...summary(agent), feeTxHash: existing.txHash });
1405
1629
  }
1406
1630
  catch (err) {
1407
- return fail(err.code ?? 'DEPLOY_FAILED', `${err.message}.${paidNote()}`);
1631
+ const code = err.code;
1632
+ if (code === 'DEPLOY_FEE_NOT_FOUND' && existing.chainId === undefined) {
1633
+ return unchainedFeeNotFound(idempotencyKey, existing.txHash, currentFee);
1634
+ }
1635
+ return fail(code ?? 'DEPLOY_FAILED', `${err.message}.${paidNote()}`);
1408
1636
  }
1409
1637
  }
1410
1638
  let terms;
@@ -1419,6 +1647,24 @@ export function registerRentTools(server, cfg, walletCtx) {
1419
1647
  }
1420
1648
  const fee = terms.required ? terms : null;
1421
1649
  const feeText = fee ? `${formatUnits(BigInt(fee.amountRaw), fee.decimals).replace(/\.0$/, '')} USDC` : 'none';
1650
+ // The fee terms and the agent this call would pay for, bound into the
1651
+ // quote: the confirm re-reads the terms, and pays only what was quoted.
1652
+ const spend = {
1653
+ idempotencyKey,
1654
+ payFrom: walletCtx.wallet.address.toLowerCase(),
1655
+ name,
1656
+ provider,
1657
+ model,
1658
+ instructions: sha256Hex(Buffer.from(instructions, 'utf8')),
1659
+ skills: JSON.stringify(skillSlugs ?? []),
1660
+ feeRequired: fee !== null,
1661
+ feeChain: fee?.chain ?? null,
1662
+ feeChainId: fee?.chainId ?? null,
1663
+ feeToken: fee ? String(fee.token).toLowerCase() : null,
1664
+ feeRecipient: fee ? String(fee.recipient).toLowerCase() : null,
1665
+ feeAmountRaw: fee ? String(fee.amountRaw) : null,
1666
+ feeDecimals: fee?.decimals ?? null,
1667
+ };
1422
1668
  if (!confirm) {
1423
1669
  const invalid = await validate();
1424
1670
  if (invalid)
@@ -1436,7 +1682,7 @@ export function registerRentTools(server, cfg, walletCtx) {
1436
1682
  }
1437
1683
  }
1438
1684
  }
1439
- const quote = createQuote('deploy', { name, provider, model, fee: feeText });
1685
+ const quote = createQuote('deploy', { name, provider, model, fee: feeText }, spend);
1440
1686
  return ok({
1441
1687
  quote: {
1442
1688
  agent: { name, provider, model, skills: skillSlugs ?? [] },
@@ -1450,8 +1696,12 @@ export function registerRentTools(server, cfg, walletCtx) {
1450
1696
  next: `Re-call deploy_agent with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to ${fee ? 'pay the fee and ' : ''}deploy.`,
1451
1697
  });
1452
1698
  }
1453
- if (!quoteId || !consumeQuote(quoteId, 'deploy')) {
1454
- 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)');
1699
+ const check = consumeQuote(quoteId, 'deploy', spend);
1700
+ if (!check.ok) {
1701
+ const feeChanged = check.code === 'QUOTE_MISMATCH' && check.changed.some((k) => k.startsWith('fee'))
1702
+ ? `the deploy fee changed since the quote (quoted ${check.quote.summary.fee}, now ${feeText}${fee ? ` to ${fee.recipient} on ${fee.chain}` : ''})`
1703
+ : undefined;
1704
+ return quoteRefused(check, 'deploy_agent', feeChanged);
1455
1705
  }
1456
1706
  try {
1457
1707
  const now = new Date().toISOString();
@@ -1466,7 +1716,7 @@ export function registerRentTools(server, cfg, walletCtx) {
1466
1716
  if (invalid)
1467
1717
  return invalid;
1468
1718
  const w = await walletOn(fee.chain, fee.chainId);
1469
- putSpend({ idempotencyKey, kind: 'deploy', stage: 'created', settlement: fee.chain, token: fee.token, amountWei: fee.amountRaw, createdAt: now, updatedAt: now });
1719
+ putSpend({ idempotencyKey, kind: 'deploy', stage: 'created', settlement: fee.chain, chainId: fee.chainId, token: fee.token, amountWei: fee.amountRaw, createdAt: now, updatedAt: now });
1470
1720
  const data = new Interface(['function transfer(address to, uint256 amount) returns (bool)'])
1471
1721
  .encodeFunctionData('transfer', [fee.recipient, BigInt(fee.amountRaw)]);
1472
1722
  const tx = await w.sendTransaction({ to: fee.token, data });
@@ -1491,6 +1741,7 @@ export function registerRentTools(server, cfg, walletCtx) {
1491
1741
  updateSpend(idempotencyKey, { stage: 'confirmed', agentId: agent.id });
1492
1742
  return ok({
1493
1743
  ...summary(agent),
1744
+ fee: feeText,
1494
1745
  feeTxHash,
1495
1746
  hint: agent.started ? 'The agent is running.' : 'The agent was created but did not start — start it with start_agent.',
1496
1747
  });