@blindmarket/mcp-server 0.3.5 → 0.4.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
@@ -29,6 +29,16 @@ const ESCROW_READ_ABI = [
29
29
  */
30
30
  const ZERO_TOKEN = '0x0000000000000000000000000000000000000000';
31
31
  const GAS_LIMIT = 1000000n; // matches the canonical rent script
32
+ // Auto-verify releases the payment, so the bar can't be "one character" — but
33
+ // 40 made a correct 30-character URL unpayable. 20 is the platform floor
34
+ // (DEFAULT_MIN_CONTENT_CHARS in the backend's autoVerify, where min_length is a
35
+ // hard floor). Accepted limit: a one-word correct answer still can't
36
+ // auto-verify without an expected_answer. MUST match the web app's
37
+ // RENTAL_VERIFICATION_CRITERIA (frontend/src/components/UseServiceModal.tsx and
38
+ // UseFromAgentModal.tsx) so a rental is judged the same whichever client paid
39
+ // for it. The index route also rejects 'auto' with no real criterion (400
40
+ // AUTO_CRITERIA_REQUIRED).
41
+ const RENTAL_VERIFICATION_CRITERIA = { min_length: 20 };
32
42
  function ok(data) {
33
43
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
34
44
  }
@@ -85,8 +95,9 @@ export function registerRentTools(server, cfg, walletCtx) {
85
95
  }
86
96
  const settlement = createSettlementResolver({ apiBase: cfg.apiBase ?? 'https://api.blindmarket.xyz', api });
87
97
  /** 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. */
98
+ * On a relay chain (Base) nothing signs locally — the relay signs from the
99
+ * API key's owner wallet — so a missing BLINDMARKET_PRIVATE_KEY is not an
100
+ * error there. */
90
101
  async function requireFunding() {
91
102
  let s;
92
103
  try {
@@ -95,26 +106,44 @@ export function registerRentTools(server, cfg, walletCtx) {
95
106
  catch (err) {
96
107
  return { error: fail(err.code ?? 'SETTLEMENT_UNKNOWN', err.message) };
97
108
  }
98
- if (s.mode === 'base')
109
+ if (s.payment === 'relay-erc20')
99
110
  return { s, payFrom: s.payFrom };
100
111
  if (!walletCtx) {
101
112
  return { error: fail('NO_WALLET', 'Spending on 0G needs a local funding wallet — set BLINDMARKET_PRIVATE_KEY (see wallet_status)') };
102
113
  }
114
+ // The escrow address is compared before every send, but not the chain:
115
+ // a testnet backend's escrow address, paid on the mainnet RPC, is some
116
+ // other account there. A backend that names its 0G chain id settles it.
117
+ if (s.chainId !== undefined && walletCtx.chainId !== s.chainId) {
118
+ return {
119
+ error: fail('CHAIN_MISMATCH', `The backend settles 0G on chain ${s.chainId}, but BLINDMARKET_PRIVATE_KEY signs on chain ${walletCtx.chainId} (BLINDMARKET_RPC_URL / BLINDMARKET_CHAIN_ID). Native value sent there would not reach this backend's escrow. Point both at chain ${s.chainId}.`),
120
+ };
121
+ }
103
122
  return { s, payFrom: walletCtx.wallet.address };
104
123
  }
124
+ /** A new escrow is funded where POST /api/v1/tasks builds: the backend's
125
+ * posting chain. A process forced onto another chain (to finish or refund
126
+ * tasks already there) cannot post. Unknown on an older backend. */
127
+ function notPostingChain(s) {
128
+ if (s.postingChain === undefined || s.mode === s.postingChain)
129
+ return null;
130
+ const e = new Error(`This process settles on ${s.mode} (BLINDMARKET_SETTLEMENT), but the backend posts new tasks on ${s.postingChain}, so a new escrow can only be funded there. Unset BLINDMARKET_SETTLEMENT (or set it to ${s.postingChain}) to post; ${s.mode} stays usable for tasks already on it.`);
131
+ e.code = 'NOT_POSTING_CHAIN';
132
+ return e;
133
+ }
105
134
  const ERC20 = new Interface([
106
135
  'function allowance(address owner, address spender) view returns (uint256)',
107
136
  'function approve(address spender, uint256 amount) returns (bool)',
108
137
  'function balanceOf(address owner) view returns (uint256)',
109
138
  ]);
110
- function usdc(s) {
111
- return new Contract(s.usdcAddress, ERC20, s.provider);
139
+ function settlementToken(s) {
140
+ return new Contract(s.token.address, ERC20, s.provider);
112
141
  }
113
142
  /** Spendable balance of whoever pays, in the settlement token's units. */
114
143
  async function payFromBalance(s, payFrom) {
115
144
  try {
116
- const raw = s.mode === 'base'
117
- ? await usdc(s).balanceOf(payFrom)
145
+ const raw = s.payment === 'relay-erc20'
146
+ ? await settlementToken(s).balanceOf(payFrom)
118
147
  : await walletCtx.provider.getBalance(payFrom);
119
148
  return formatUnits(raw, s.decimals);
120
149
  }
@@ -126,19 +155,22 @@ export function registerRentTools(server, cfg, walletCtx) {
126
155
  * the task, and the tx carries no chainId. Discovery is only a hint about
127
156
  * that chain (see settlement.ts). So before broadcasting, check the tx
128
157
  * 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. */
158
+ * relay chain's address on the 0G RPC, or a 0G refund is relayed onto
159
+ * another chain. A mismatch also drops the cached mode so the next call
160
+ * re-asks. */
131
161
  async function verifyTarget(s, to, what) {
132
162
  const target = String(to).toLowerCase();
133
- const expected = s.mode === 'base' ? s.escrowAddress : s.escrowAddress;
163
+ const expected = s.escrowAddress;
134
164
  if (expected) {
135
165
  if (target === expected.toLowerCase())
136
166
  return;
137
167
  settlement.invalidate();
138
168
  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.'));
169
+ (s.escrowChains
170
+ ? `This task is escrowed on another chain — set BLINDMARKET_SETTLEMENT to the chain that holds it (this backend has escrows on ${s.escrowChains.join(', ')}; 0G needs a local key), or use the web app.`
171
+ : s.payment === 'relay-erc20'
172
+ ? 'This task is escrowed on 0G — handle it with BLINDMARKET_SETTLEMENT=0g and a local key, or from the web app.'
173
+ : 'The backend is building Base transactions — re-run and discovery will re-check, or set BLINDMARKET_SETTLEMENT=base.'));
142
174
  e.code = 'ESCROW_MISMATCH';
143
175
  throw e;
144
176
  }
@@ -204,17 +236,17 @@ export function registerRentTools(server, cfg, walletCtx) {
204
236
  e.code = 'TX_PENDING';
205
237
  throw e;
206
238
  }
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. */
239
+ /** Relay chains only: createTask pulls the ERC-20 via transferFrom, so the
240
+ * escrow needs an allowance first. Confirmed by re-reading allowance()
241
+ * rather than by receipt, which is what makes the user-op case decidable. */
210
242
  async function ensureAllowance(s, record) {
211
243
  const need = BigInt(record.amountWei);
212
- const token = usdc(s);
244
+ const token = settlementToken(s);
213
245
  if ((await token.allowance(s.payFrom, s.escrowAddress)) >= need)
214
246
  return;
215
247
  const approve = async () => {
216
248
  const data = ERC20.encodeFunctionData('approve', [s.escrowAddress, need]);
217
- const { hash } = await relaySend(s, { to: s.usdcAddress, data });
249
+ const { hash } = await relaySend(s, { to: s.token.address, data });
218
250
  // Persist BEFORE waiting: a crash here must resume into the poll below.
219
251
  updateSpend(record.idempotencyKey, { stage: 'approved', approveTxHash: hash });
220
252
  record.stage = 'approved';
@@ -242,19 +274,19 @@ export function registerRentTools(server, cfg, walletCtx) {
242
274
  await approve();
243
275
  if (await settled())
244
276
  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`);
277
+ const e = new Error(`${s.symbol} allowance still below ${formatUnits(need, s.decimals)} after two approves (last ${record.approveTxHash}) — check the relay wallet's ${s.symbol} balance and retry with the same idempotencyKey`);
246
278
  e.code = 'APPROVE_PENDING';
247
279
  throw e;
248
280
  }
249
281
  /** Fund escrow + index — the shared tail of rent_service and post_task.
250
282
  * Resumable at every stage via the spend ledger.
251
283
  *
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. */
284
+ * Two funding paths, chosen by `payment`:
285
+ * local-native — native 0G from the local wallet, signed and sent here.
286
+ * relay-erc20 — the ERC-20 (USDC on Base) via transferFrom: approve
287
+ * first (ensureAllowance), then the createTask the backend built,
288
+ * both through the Privy relay with no local signing at all. The
289
+ * backend picks the escrow; we only check it is the one we approved. */
258
290
  async function fundAndIndex(record) {
259
291
  const s = await settlement();
260
292
  let { txHash } = record;
@@ -267,11 +299,14 @@ export function registerRentTools(server, cfg, walletCtx) {
267
299
  throw e;
268
300
  }
269
301
  if (record.stage === 'created' || record.stage === 'approved') {
270
- if (s.mode === 'base')
302
+ const refused = notPostingChain(s);
303
+ if (refused)
304
+ throw refused;
305
+ if (s.payment === 'relay-erc20')
271
306
  await ensureAllowance(s, record);
272
- const { unsignedTx } = await api('POST', '/api/v1/tasks', {
307
+ const { unsignedTx, chain: builtChain, chainId: builtChainId } = await api('POST', '/api/v1/tasks', {
273
308
  taskHash: record.taskHash,
274
- token: s.mode === 'base' ? s.usdcAddress : ZERO_TOKEN,
309
+ token: s.payment === 'relay-erc20' ? s.token.address : ZERO_TOKEN,
275
310
  amount: record.amountWei,
276
311
  locationZone: 'global',
277
312
  duration: String(record.durationSecs ?? 3600),
@@ -282,10 +317,19 @@ export function registerRentTools(server, cfg, walletCtx) {
282
317
  rootHash: record.rootHash,
283
318
  wrappedKeys: record.privacy === 'public' ? undefined : record.wrappedKeys,
284
319
  });
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.
320
+ // Either branch: the tx must target the escrow this mode expects. On a
321
+ // relay chain that is also the escrow the allowance above was granted to.
322
+ // A chain-aware backend also names the chain it built for (older ones
323
+ // return only the tx, and are checked by address alone, as before).
324
+ if (s.postingChain !== undefined && ((builtChain !== undefined && builtChain !== s.mode) ||
325
+ (builtChainId !== undefined && s.chainId !== undefined && Number(builtChainId) !== s.chainId))) {
326
+ settlement.invalidate();
327
+ const e = new Error(`backend built createTask for ${builtChain ?? '?'} (chain ${builtChainId ?? '?'}) but this process settles on ${s.mode} (chain ${s.chainId ?? '?'}) — re-run and discovery will re-check`);
328
+ e.code = 'ESCROW_MISMATCH';
329
+ throw e;
330
+ }
287
331
  await verifyTarget(s, unsignedTx.to, 'createTask');
288
- if (s.mode === 'base') {
332
+ if (s.payment === 'relay-erc20') {
289
333
  const { hash, isUserOp, gas } = await relaySend(s, { to: unsignedTx.to, data: unsignedTx.data });
290
334
  // Persist BEFORE waiting, same reasoning as the 0G branch below.
291
335
  updateSpend(record.idempotencyKey, { stage: 'funded', txHash: hash, isUserOp, gas });
@@ -300,6 +344,8 @@ export function registerRentTools(server, cfg, walletCtx) {
300
344
  data: unsignedTx.data,
301
345
  value: BigInt(record.amountWei),
302
346
  gasLimit: GAS_LIMIT,
347
+ // ethers refuses to send when its provider is on another chain.
348
+ ...(s.chainId !== undefined ? { chainId: s.chainId } : {}),
303
349
  });
304
350
  // Persist the tx hash BEFORE waiting: if we crash mid-confirmation the
305
351
  // resume path re-runs /tasks/index with this hash instead of re-funding.
@@ -383,6 +429,9 @@ export function registerRentTools(server, cfg, walletCtx) {
383
429
  return fail(err.code ?? 'RESUME_FAILED', err.message);
384
430
  }
385
431
  }
432
+ const offPosting = notPostingChain(s);
433
+ if (offPosting)
434
+ return fail(offPosting.code, offPosting.message);
386
435
  const service = await api('GET', `/api/v1/marketplace/services/${serviceId}`);
387
436
  const isPublic = privacy === 'public';
388
437
  if (!isPublic && !service.agent_public_key) {
@@ -395,10 +444,10 @@ export function registerRentTools(server, cfg, walletCtx) {
395
444
  // A service priced in 18-decimal units on a 6-decimal chain would quote
396
445
  // as a trillion USDC and relay an approve for it before createTask
397
446
  // 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
447
+ // older backend may still serve one. 1,000,000 tokens per call is far
399
448
  // 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.`);
449
+ if (s.decimals < 18 && priceRaw > 1000000n * 10n ** BigInt(s.decimals)) {
450
+ 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.`);
402
451
  }
403
452
  const price = formatUnits(priceRaw, s.decimals);
404
453
  if (!confirm) {
@@ -453,11 +502,11 @@ export function registerRentTools(server, cfg, walletCtx) {
453
502
  wrappedKeys,
454
503
  publicBrief: isPublic ? prompt.slice(0, 4000) : undefined,
455
504
  verificationMode: 'auto',
456
- verificationCriteria: { min_length: 1 },
505
+ verificationCriteria: RENTAL_VERIFICATION_CRITERIA,
457
506
  requiredCapabilities: [],
458
507
  amountWei: String(service.price_raw),
459
508
  settlement: s.mode,
460
- token: s.mode === 'base' ? s.usdcAddress : ZERO_TOKEN,
509
+ token: s.payment === 'relay-erc20' ? s.token.address : ZERO_TOKEN,
461
510
  durationSecs: 3600,
462
511
  createdAt: new Date().toISOString(),
463
512
  updatedAt: new Date().toISOString(),
@@ -508,6 +557,9 @@ export function registerRentTools(server, cfg, walletCtx) {
508
557
  return fail(err.code ?? 'RESUME_FAILED', err.message);
509
558
  }
510
559
  }
560
+ const offPosting = notPostingChain(s);
561
+ if (offPosting)
562
+ return fail(offPosting.code, offPosting.message);
511
563
  const isPublic = privacy === 'public';
512
564
  const amountStr = amount ?? amount0G;
513
565
  if (!amountStr) {
@@ -585,7 +637,7 @@ export function registerRentTools(server, cfg, walletCtx) {
585
637
  requiredCapabilities: capabilities ?? [],
586
638
  amountWei: amountWei.toString(),
587
639
  settlement: s.mode,
588
- token: s.mode === 'base' ? s.usdcAddress : ZERO_TOKEN,
640
+ token: s.payment === 'relay-erc20' ? s.token.address : ZERO_TOKEN,
589
641
  durationSecs: durationSeconds ?? 86400,
590
642
  createdAt: new Date().toISOString(),
591
643
  updatedAt: new Date().toISOString(),
@@ -633,7 +685,8 @@ export function registerRentTools(server, cfg, walletCtx) {
633
685
  //
634
686
  // Chain note: the backend resolves which chain holds the task
635
687
  // (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
688
+ // decides who signs — the local wallet on 0G, the Privy relay on a relay
689
+ // chain (Base) — and
637
690
  // verifies the tx targets the escrow that mode expects before sending
638
691
  // (verifyTarget). Task state is read from the chain the mode names, not
639
692
  // from GET /tasks/:id, which is bound to the 0G escrow.
@@ -643,32 +696,55 @@ export function registerRentTools(server, cfg, walletCtx) {
643
696
  }
644
697
  /** Resolve a task id-or-hash to its live on-chain state.
645
698
  *
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. */
699
+ * GET /api/v1/tasks/:id accepts either form — but older backends read the
700
+ * 0G escrow only (escrowService.getTask was bound to the 0G contract), so
701
+ * on a relay chain its struct is for whatever 0G task happens to share the
702
+ * id. On a relay chain we use it just to turn a hash into an id, then read
703
+ * the struct from that chain's escrow ourselves over the read-only
704
+ * provider. */
651
705
  async function loadTask(s, task) {
652
- if (s.mode !== 'base') {
653
- return api('GET', `/api/v1/tasks/${encodeURIComponent(task)}`);
706
+ /** The backend said the task is on another chain: its id means a different task here. */
707
+ const onOtherChain = (taskId, chain) => {
708
+ const label = s.chain === 'base' ? 'Base' : s.chain;
709
+ const e = new Error(`task ${taskId} is a ${chain} task, and this process settles on ${label} — handle it with BLINDMARKET_SETTLEMENT=${chain}`);
710
+ // The code names the chain; on Base it is the TASK_NOT_ON_BASE it always was.
711
+ e.code = `TASK_NOT_ON_${s.chain.toUpperCase().replace(/-/g, '_')}`;
712
+ return e;
713
+ };
714
+ if (s.payment !== 'relay-erc20') {
715
+ const detail = await api('GET', `/api/v1/tasks/${encodeURIComponent(task)}`);
716
+ if (detail.chain && detail.chain !== s.chain)
717
+ throw onOtherChain(detail.taskId, detail.chain);
718
+ return detail;
654
719
  }
655
- // Base: the escrow is the authority, and we can read it directly. Only ask
720
+ // Relay chain: the escrow is the authority, and we can read it directly. Only ask
656
721
  // the backend when we need a hash resolved to an id — that endpoint makes
657
722
  // several Redis round trips and is the slowest thing in this path, so a
658
723
  // numeric id must not pay for it.
659
724
  let taskId;
725
+ let backendChain;
660
726
  if (/^\d+$/.test(task)) {
661
727
  taskId = task;
662
728
  }
663
729
  else {
664
730
  const viaBackend = await api('GET', `/api/v1/tasks/${encodeURIComponent(task)}`);
665
731
  taskId = viaBackend.taskId;
732
+ backendChain = viaBackend.chain;
733
+ // The same id can exist on this chain's escrow too (another task of the
734
+ // same poster): reading it would act on the wrong task.
735
+ if (backendChain && backendChain !== s.chain)
736
+ throw onOtherChain(taskId, backendChain);
666
737
  }
667
738
  const escrow = new Contract(s.escrowAddress, ESCROW_READ_ABI, s.provider);
668
739
  const t = await escrow.getTask(BigInt(taskId));
669
740
  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';
741
+ const label = s.chain === 'base' ? 'Base' : s.chain;
742
+ const hint = s.escrowChains
743
+ ? `it is on another chain; set BLINDMARKET_SETTLEMENT to the one that holds it (this backend has escrows on ${s.escrowChains.join(', ')})`
744
+ : 'it is probably a 0G task; handle it with BLINDMARKET_SETTLEMENT=0g';
745
+ const e = new Error(`task ${taskId} does not exist on the ${label} escrow ${s.escrowAddress} — ${hint}`);
746
+ // The code names the chain; on Base it is the TASK_NOT_ON_BASE it always was.
747
+ e.code = `TASK_NOT_ON_${s.chain.toUpperCase().replace(/-/g, '_')}`;
672
748
  throw e;
673
749
  }
674
750
  return {
@@ -682,8 +758,8 @@ export function registerRentTools(server, cfg, walletCtx) {
682
758
  decimals: s.decimals,
683
759
  };
684
760
  }
685
- /** Amount formatted against the task's OWN decimals — a Base task settles in
686
- * USDC (6), not the wallet's native 18. */
761
+ /** Amount formatted against the task's OWN decimals — a relay-chain task
762
+ * settles in its ERC-20 (USDC: 6), not the wallet's native 18. */
687
763
  function refundAmount(detail) {
688
764
  return formatUnits(BigInt(detail.amount), detail.decimals ?? 18);
689
765
  }
@@ -704,7 +780,7 @@ export function registerRentTools(server, cfg, walletCtx) {
704
780
  }
705
781
  /** Broadcast the refund and wait for it — the shared tail of both tools.
706
782
  * 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.
783
+ * Same two paths as fundAndIndex: local wallet on 0G, Privy relay on a relay chain.
708
784
  * The backend resolves which chain holds the task and builds the tx for
709
785
  * it; this only decides who signs. */
710
786
  async function sendRefund(record) {
@@ -721,10 +797,10 @@ export function registerRentTools(server, cfg, walletCtx) {
721
797
  const { unsignedTx } = await api('POST', `/api/v1/tasks/${taskId}/${route}`);
722
798
  // The backend resolves the chain that holds the task and builds for it;
723
799
  // 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.
800
+ // broadcasts on, stop here — relaying a 0G refund onto another chain
801
+ // lands on an address with no escrow and burns the gas.
726
802
  await verifyTarget(s, unsignedTx.to, `${route}Task`);
727
- if (s.mode === 'base') {
803
+ if (s.payment === 'relay-erc20') {
728
804
  const { hash, isUserOp, gas } = await relaySend(s, { to: unsignedTx.to, data: unsignedTx.data });
729
805
  // Persist BEFORE waiting, same reasoning as the 0G branch below.
730
806
  updateSpend(record.idempotencyKey, { stage: 'sent', txHash: hash, isUserOp, gas });
@@ -738,6 +814,7 @@ export function registerRentTools(server, cfg, walletCtx) {
738
814
  to: unsignedTx.to,
739
815
  data: unsignedTx.data,
740
816
  gasLimit: GAS_LIMIT,
817
+ ...(s.chainId !== undefined ? { chainId: s.chainId } : {}),
741
818
  });
742
819
  // Persist the hash BEFORE waiting, same reasoning as fundAndIndex: a
743
820
  // crash mid-confirmation must resume onto THIS tx, not broadcast another.
@@ -747,7 +824,7 @@ export function registerRentTools(server, cfg, walletCtx) {
747
824
  }
748
825
  }
749
826
  else if (txHash) {
750
- if (s.mode === 'base')
827
+ if (s.payment === 'relay-erc20')
751
828
  await waitRelayed(s, txHash, record.isUserOp ?? false);
752
829
  else
753
830
  await walletCtx.provider.waitForTransaction(txHash);
@@ -1007,7 +1084,7 @@ export function registerRentTools(server, cfg, walletCtx) {
1007
1084
  });
1008
1085
  server.registerTool('complete_task', {
1009
1086
  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.',
1087
+ description: 'Executor side, after accept_task: submits your result, sends submitEvidence from YOUR wallet (Base: through the backend relay, gas in USDC; 0G: signed locally with BLINDMARKET_PRIVATE_KEY), then asks the backend to verify and release the escrow to you. This is the ONLY delivery tool — there is no separate submit step. If an earlier call died between submitting and broadcasting (task stuck "submitted" off-chain, Assigned on-chain), re-calling heals it via the /rebroadcast endpoint. If the last verification FAILED, calling this again resubmits — the contract allows up to 3 attempts before the deadline. Safe to re-call: it resumes from whatever stage the escrow shows.',
1011
1088
  inputSchema: {
1012
1089
  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
1090
  output: z.string().min(1).max(200_000).describe('Your result. Verification judges this text (auto mode scores it against the poster\'s criteria).'),
@@ -1019,7 +1096,7 @@ export function registerRentTools(server, cfg, walletCtx) {
1019
1096
  return f.error;
1020
1097
  const { s, payFrom } = f;
1021
1098
  // requireFunding returns an error on 0G without a wallet, so walletCtx is
1022
- // non-null whenever s.mode is '0g' from here on.
1099
+ // non-null whenever s.payment is 'local-native' from here on.
1023
1100
  let detail;
1024
1101
  try {
1025
1102
  detail = await loadTask(s, task);
@@ -1055,12 +1132,27 @@ export function registerRentTools(server, cfg, walletCtx) {
1055
1132
  }
1056
1133
  let submitTxHash;
1057
1134
  let gas;
1135
+ let healed = false;
1058
1136
  try {
1059
1137
  if (status === 1 || isRetry) {
1060
- const sub = await api('POST', `/api/v1/a2a/tasks/${task}/submit`, { resultData: { output } });
1138
+ let sub;
1139
+ try {
1140
+ sub = await api('POST', `/api/v1/a2a/tasks/${task}/submit`, { resultData: { output } });
1141
+ }
1142
+ catch (err) {
1143
+ // Stranded 'submitted': /submit flips the off-chain state when the
1144
+ // tx is BUILT, so an earlier call that died before broadcasting
1145
+ // leaves the chain at Assigned(1) while /submit refuses a rebuild.
1146
+ // /rebroadcast rebuilds the same tx from the stored result — the
1147
+ // FIRST output is what gets delivered, not this call's.
1148
+ if (err.code !== 'INVALID_STATE' || status !== 1)
1149
+ throw err;
1150
+ sub = await api('POST', `/api/v1/a2a/tasks/${task}/rebroadcast`);
1151
+ healed = true;
1152
+ }
1061
1153
  const tx = sub.unsignedSubmitEvidence;
1062
1154
  await verifyTarget(s, tx.to, 'submitEvidence');
1063
- if (s.mode === 'base') {
1155
+ if (s.payment === 'relay-erc20') {
1064
1156
  const sent = await relaySend(s, { to: tx.to, data: tx.data });
1065
1157
  submitTxHash = sent.hash;
1066
1158
  gas = sent.gas;
@@ -1098,13 +1190,14 @@ export function registerRentTools(server, cfg, walletCtx) {
1098
1190
  taskHash: detail.taskHash,
1099
1191
  submitTxHash,
1100
1192
  gas,
1193
+ ...(healed ? { rebroadcast: true, note: 'An earlier submission for this task never reached the chain; its stored result was re-broadcast. The output passed to THIS call was not used.' } : {}),
1101
1194
  verification: fin.verificationResult ?? null,
1102
1195
  backendStatus: fin.status,
1103
1196
  onChainStatus: statusName(Number(after.status)),
1104
1197
  paidTo: done ? payFrom : undefined,
1105
1198
  submissionAttempts: after.submissionAttempts,
1106
1199
  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}.`
1200
+ ? `Escrow released: ${formatUnits(BigInt(after.amount), after.decimals ?? s.decimals)} ${s.symbol} minus the marketplace fee is now in ${payFrom}.`
1108
1201
  : fin.awaitingPosterApproval
1109
1202
  ? 'Manual-verification task: the poster must approve via verify_task before the escrow releases.'
1110
1203
  : `Verification did not pass (${(fin.verificationResult?.reasons ?? []).join('; ') || 'no reasons given'}). ` +