@blindmarket/mcp-server 0.4.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,8 +1,8 @@
1
1
  import { z } from 'zod';
2
- import { Contract, Interface, formatUnits, parseUnits } from 'ethers';
3
- import { aesDecrypt, aesEncrypt, eciesDecrypt, eciesEncrypt, generateAesKey, sha256Hex } from './crypto.js';
4
- import { createQuote, consumeQuote, getSpend, putSpend, updateSpend } from './state.js';
5
- import { createSettlementResolver } from './settlement.js';
2
+ import { Contract, Interface, JsonRpcProvider, formatUnits, keccak256, parseUnits, toUtf8Bytes } from 'ethers';
3
+ import { aesDecrypt, aesEncrypt, derivePublicKeyHex, eciesDecrypt, eciesEncrypt, generateAesKey, sha256Hex } from './crypto.js';
4
+ import { createQuote, consumeQuote, getSpend, putSpend, updateSpend, } from './state.js';
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;
8
8
  // a post-#38 deployment appends disputedAt, which ABI decoding ignores.
@@ -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
@@ -93,11 +165,16 @@ export function registerRentTools(server, cfg, walletCtx) {
93
165
  }
94
166
  return json.data;
95
167
  }
96
- const settlement = createSettlementResolver({ apiBase: cfg.apiBase ?? 'https://api.blindmarket.xyz', api });
168
+ const settlement = createSettlementResolver({
169
+ apiBase: cfg.apiBase ?? 'https://api.blindmarket.xyz',
170
+ api,
171
+ localWallet: walletCtx?.wallet.address,
172
+ });
97
173
  /** How this process pays. On 0G that is the local wallet, which must exist.
98
174
  * On a relay chain (Base) nothing signs locally — the relay signs from the
99
175
  * API key's owner wallet — so a missing BLINDMARKET_PRIVATE_KEY is not an
100
- * error there. */
176
+ * error there. On an ERC-20 chain without a relay (Arc) discovery only
177
+ * succeeds with the local wallet, already checked to be the key's owner. */
101
178
  async function requireFunding() {
102
179
  let s;
103
180
  try {
@@ -106,7 +183,7 @@ export function registerRentTools(server, cfg, walletCtx) {
106
183
  catch (err) {
107
184
  return { error: fail(err.code ?? 'SETTLEMENT_UNKNOWN', err.message) };
108
185
  }
109
- if (s.payment === 'relay-erc20')
186
+ if (isErc20Settlement(s))
110
187
  return { s, payFrom: s.payFrom };
111
188
  if (!walletCtx) {
112
189
  return { error: fail('NO_WALLET', 'Spending on 0G needs a local funding wallet — set BLINDMARKET_PRIVATE_KEY (see wallet_status)') };
@@ -124,10 +201,23 @@ export function registerRentTools(server, cfg, walletCtx) {
124
201
  /** A new escrow is funded where POST /api/v1/tasks builds: the backend's
125
202
  * posting chain. A process forced onto another chain (to finish or refund
126
203
  * tasks already there) cannot post. Unknown on an older backend. */
127
- function notPostingChain(s) {
128
- if (s.postingChain === undefined || s.mode === s.postingChain)
204
+ async function notPostingChain(s) {
205
+ let posting = s.postingChain;
206
+ // Forced to 0G, discovery never asked the backend. Ask now, before a quote:
207
+ // a backend that posts elsewhere refuses the native-0G createTask only
208
+ // after the brief is uploaded and its hash claimed.
209
+ if (posting === undefined && s.mode === '0g') {
210
+ try {
211
+ const res = await fetch(`${cfg.apiBase}/health/settlement`, { signal: AbortSignal.timeout(15_000) });
212
+ const json = await res.json();
213
+ if (json?.success && typeof json.data?.postingChain === 'string')
214
+ posting = json.data.postingChain;
215
+ }
216
+ catch { /* an older backend or a blip: keep the old behaviour, the escrow check still guards the send */ }
217
+ }
218
+ if (posting === undefined || s.mode === posting)
129
219
  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.`);
220
+ const e = new Error(`This process settles on ${s.mode} (BLINDMARKET_SETTLEMENT), but the backend posts new tasks on ${posting}, so a new escrow can only be funded there. Unset BLINDMARKET_SETTLEMENT (or set it to ${posting}) to post; ${s.mode} stays usable for tasks already on it.`);
131
221
  e.code = 'NOT_POSTING_CHAIN';
132
222
  return e;
133
223
  }
@@ -139,10 +229,15 @@ export function registerRentTools(server, cfg, walletCtx) {
139
229
  function settlementToken(s) {
140
230
  return new Contract(s.token.address, ERC20, s.provider);
141
231
  }
232
+ /** The local wallet on a no-relay ERC-20 chain, over that chain's RPC
233
+ * (checked at discovery to serve the chain the backend names). */
234
+ function localSigner(s) {
235
+ return walletCtx.wallet.connect(s.provider);
236
+ }
142
237
  /** Spendable balance of whoever pays, in the settlement token's units. */
143
238
  async function payFromBalance(s, payFrom) {
144
239
  try {
145
- const raw = s.payment === 'relay-erc20'
240
+ const raw = isErc20Settlement(s)
146
241
  ? await settlementToken(s).balanceOf(payFrom)
147
242
  : await walletCtx.provider.getBalance(payFrom);
148
243
  return formatUnits(raw, s.decimals);
@@ -213,10 +308,24 @@ export function registerRentTools(server, cfg, walletCtx) {
213
308
  // default it applies to this body: user-pays.
214
309
  return { hash: r.hash, isUserOp: r.isUserOp === true, gas: r.gas ?? 'user-pays' };
215
310
  }
311
+ /** Send a tx on an ERC-20 settlement: through the relay, or signed by the
312
+ * local wallet on a chain the relay does not serve. Either way the caller
313
+ * persists the hash before waiting (waitRelayed polls the chain's own RPC
314
+ * for a plain hash, which a local send always is). `nonce` pins a local
315
+ * send right after one this process made: an RPC can answer the next nonce
316
+ * lookup from before the earlier tx landed. */
317
+ async function sendErc20(s, tx, nonce) {
318
+ if (s.payment === 'relay-erc20')
319
+ return relaySend(s, tx);
320
+ // No gasLimit: estimation runs first, so a predictable revert (a stale
321
+ // allowance, a passed deadline) fails here without being mined and paid for.
322
+ const sent = await localSigner(s).sendTransaction({ to: tx.to, data: tx.data, ...(nonce !== undefined ? { nonce } : {}) });
323
+ return { hash: sent.hash, isUserOp: false, nonce: sent.nonce };
324
+ }
216
325
  /** Wait for a relayed tx to land. A plain hash can be polled for its receipt;
217
326
  * a user-op hash cannot (getTransactionReceipt is always null for it), so
218
327
  * that case returns at once and the caller confirms by on-chain STATE —
219
- * see ensureAllowance and waitCancelled. */
328
+ * see ensureAllowance and waitLanded. */
220
329
  async function waitRelayed(s, hash, isUserOp) {
221
330
  if (isUserOp)
222
331
  return;
@@ -224,7 +333,7 @@ export function registerRentTools(server, cfg, walletCtx) {
224
333
  const receipt = await s.provider.getTransactionReceipt(hash).catch(() => null);
225
334
  if (receipt) {
226
335
  if (receipt.status === 0) {
227
- const e = new Error(`relayed tx ${hash} reverted`);
336
+ const e = new Error(`tx ${hash} reverted`);
228
337
  e.code = 'TX_REVERTED';
229
338
  throw e;
230
339
  }
@@ -232,21 +341,26 @@ export function registerRentTools(server, cfg, walletCtx) {
232
341
  }
233
342
  await new Promise((r) => setTimeout(r, 3000));
234
343
  }
235
- const e = new Error(`relayed tx ${hash} not confirmed after 90s — retry with the same idempotencyKey to resume`);
344
+ const e = new Error(`tx ${hash} not confirmed after 90s — retry with the same idempotencyKey to resume`);
236
345
  e.code = 'TX_PENDING';
237
346
  throw e;
238
347
  }
239
- /** Relay chains only: createTask pulls the ERC-20 via transferFrom, so the
348
+ /** ERC-20 chains: createTask pulls the token via transferFrom, so the
240
349
  * escrow needs an allowance first. Confirmed by re-reading allowance()
241
- * rather than by receipt, which is what makes the user-op case decidable. */
350
+ * rather than by receipt, which is what makes the user-op case decidable.
351
+ * Returns the nonce the createTask should use when this call sent a local
352
+ * approve, else undefined. */
242
353
  async function ensureAllowance(s, record) {
243
354
  const need = BigInt(record.amountWei);
244
355
  const token = settlementToken(s);
245
356
  if ((await token.allowance(s.payFrom, s.escrowAddress)) >= need)
246
- return;
357
+ return undefined;
358
+ let nextNonce;
247
359
  const approve = async () => {
248
360
  const data = ERC20.encodeFunctionData('approve', [s.escrowAddress, need]);
249
- const { hash } = await relaySend(s, { to: s.token.address, data });
361
+ const { hash, nonce } = await sendErc20(s, { to: s.token.address, data });
362
+ if (nonce !== undefined)
363
+ nextNonce = nonce + 1;
250
364
  // Persist BEFORE waiting: a crash here must resume into the poll below.
251
365
  updateSpend(record.idempotencyKey, { stage: 'approved', approveTxHash: hash });
252
366
  record.stage = 'approved';
@@ -263,18 +377,18 @@ export function registerRentTools(server, cfg, walletCtx) {
263
377
  if (record.stage === 'created') {
264
378
  await approve();
265
379
  if (await settled())
266
- return;
380
+ return nextNonce;
267
381
  }
268
382
  // Resumed at 'approved' (or the fresh approve never landed): the earlier
269
383
  // approve was dropped or reverted. Sending another is safe — ERC-20
270
384
  // approve SETS the allowance, it does not add — and it is the only way
271
385
  // out of this stage, so do it rather than leave the record stuck.
272
386
  if (await settled())
273
- return;
387
+ return nextNonce;
274
388
  await approve();
275
389
  if (await settled())
276
- return;
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`);
390
+ return nextNonce;
391
+ const e = new Error(`${s.symbol} allowance still below ${formatUnits(need, s.decimals)} after two approves (last ${record.approveTxHash}) — check ${s.payFrom}'s ${s.symbol} balance and retry with the same idempotencyKey`);
278
392
  e.code = 'APPROVE_PENDING';
279
393
  throw e;
280
394
  }
@@ -288,25 +402,36 @@ export function registerRentTools(server, cfg, walletCtx) {
288
402
  * both through the Privy relay with no local signing at all. The
289
403
  * backend picks the escrow; we only check it is the one we approved. */
290
404
  async function fundAndIndex(record) {
291
- const s = await settlement();
292
405
  let { txHash } = record;
293
- // A record remembers the chain it started on. If the backend flips mode
294
- // between attempts, re-funding through the other path would double-fund
295
- // or send native value into a USDC transferFrom — refuse instead.
296
- if (record.settlement && record.settlement !== s.mode) {
297
- 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`);
298
- e.code = 'SETTLEMENT_CHANGED';
299
- throw e;
300
- }
406
+ // Nothing left to sign once the escrow is funded: /a2a/tasks/index finds
407
+ // the receipt on whichever chain holds it. So a funded spend finishes
408
+ // even when settlement cannot be discovered right now, or has moved.
301
409
  if (record.stage === 'created' || record.stage === 'approved') {
302
- const refused = notPostingChain(s);
410
+ const s = await settlement();
411
+ // A record remembers the chain it started on. If the backend flips mode
412
+ // between attempts, re-funding through the other path would double-fund
413
+ // or send native value into a USDC transferFrom — refuse instead.
414
+ if (record.settlement && record.settlement !== s.mode) {
415
+ const e = new Error(`spend ${record.idempotencyKey} started on ${record.settlement} but this process now settles on ${s.mode}. ` +
416
+ `Nothing was funded yet (stage ${record.stage}): start a new spend with a new idempotencyKey, or set BLINDMARKET_SETTLEMENT=${record.settlement} to finish this one.`);
417
+ e.code = 'SETTLEMENT_CHANGED';
418
+ throw e;
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
+ }
428
+ const refused = await notPostingChain(s);
303
429
  if (refused)
304
430
  throw refused;
305
- if (s.payment === 'relay-erc20')
306
- await ensureAllowance(s, record);
431
+ const nonce = isErc20Settlement(s) ? await ensureAllowance(s, record) : undefined;
307
432
  const { unsignedTx, chain: builtChain, chainId: builtChainId } = await api('POST', '/api/v1/tasks', {
308
433
  taskHash: record.taskHash,
309
- token: s.payment === 'relay-erc20' ? s.token.address : ZERO_TOKEN,
434
+ token: isErc20Settlement(s) ? s.token.address : ZERO_TOKEN,
310
435
  amount: record.amountWei,
311
436
  locationZone: 'global',
312
437
  duration: String(record.durationSecs ?? 3600),
@@ -329,8 +454,17 @@ export function registerRentTools(server, cfg, walletCtx) {
329
454
  throw e;
330
455
  }
331
456
  await verifyTarget(s, unsignedTx.to, 'createTask');
332
- if (s.payment === 'relay-erc20') {
333
- const { hash, isUserOp, gas } = await relaySend(s, { to: unsignedTx.to, data: unsignedTx.data });
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);
466
+ if (isErc20Settlement(s)) {
467
+ const { hash, isUserOp, gas } = await sendErc20(s, { to: unsignedTx.to, data: unsignedTx.data }, nonce);
334
468
  // Persist BEFORE waiting, same reasoning as the 0G branch below.
335
469
  updateSpend(record.idempotencyKey, { stage: 'funded', txHash: hash, isUserOp, gas });
336
470
  record.gas = gas;
@@ -397,7 +531,7 @@ export function registerRentTools(server, cfg, walletCtx) {
397
531
  // ── rent_service ──────────────────────────────────────────────────────────
398
532
  server.registerTool('rent_service', {
399
533
  title: 'Rent an Agent Service',
400
- 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).',
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).',
401
535
  inputSchema: {
402
536
  serviceId: z.number().int().positive().describe('Service id from browse_services / get_service'),
403
537
  prompt: z.string().min(1).max(100_000).describe('What you want the agent to do'),
@@ -409,11 +543,8 @@ export function registerRentTools(server, cfg, walletCtx) {
409
543
  },
410
544
  annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
411
545
  }, async ({ serviceId, prompt, idempotencyKey, privacy, confirm, quoteId, waitSeconds }) => {
412
- const f = await requireFunding();
413
- if ('error' in f)
414
- return f.error;
415
- const { s, payFrom } = f;
416
- // Resume path — this key already spent (or partially spent).
546
+ // Resume path — this key already spent (or partially spent). Checked
547
+ // before settlement: a funded spend finishes without it.
417
548
  const existing = getSpend(idempotencyKey);
418
549
  if (existing) {
419
550
  if (existing.stage === 'indexed') {
@@ -429,7 +560,11 @@ export function registerRentTools(server, cfg, walletCtx) {
429
560
  return fail(err.code ?? 'RESUME_FAILED', err.message);
430
561
  }
431
562
  }
432
- const offPosting = notPostingChain(s);
563
+ const f = await requireFunding();
564
+ if ('error' in f)
565
+ return f.error;
566
+ const { s, payFrom } = f;
567
+ const offPosting = await notPostingChain(s);
433
568
  if (offPosting)
434
569
  return fail(offPosting.code, offPosting.message);
435
570
  const service = await api('GET', `/api/v1/marketplace/services/${serviceId}`);
@@ -450,8 +585,22 @@ export function registerRentTools(server, cfg, walletCtx) {
450
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.`);
451
586
  }
452
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
+ };
453
602
  if (!confirm) {
454
- const quote = createQuote('rent', { serviceId, price, currency: s.symbol });
603
+ const quote = createQuote('rent', { serviceId, price, currency: s.symbol }, spend);
455
604
  return ok({
456
605
  quote: {
457
606
  service: { id: service.id, name: service.name, agent: service.agent_address },
@@ -466,8 +615,12 @@ export function registerRentTools(server, cfg, walletCtx) {
466
615
  next: `Re-call rent_service with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to execute this spend.`,
467
616
  });
468
617
  }
469
- if (!quoteId || !consumeQuote(quoteId, 'rent')) {
470
- 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);
471
624
  }
472
625
  try {
473
626
  // Prepare the brief blob (the canonical script's steps 1-3).
@@ -504,9 +657,11 @@ export function registerRentTools(server, cfg, walletCtx) {
504
657
  verificationMode: 'auto',
505
658
  verificationCriteria: RENTAL_VERIFICATION_CRITERIA,
506
659
  requiredCapabilities: [],
507
- amountWei: String(service.price_raw),
660
+ // The quoted price, which the binding above just matched.
661
+ amountWei: priceRaw.toString(),
508
662
  settlement: s.mode,
509
- token: s.payment === 'relay-erc20' ? s.token.address : ZERO_TOKEN,
663
+ chainId: s.chainId,
664
+ token: isErc20Settlement(s) ? s.token.address : ZERO_TOKEN,
510
665
  durationSecs: 3600,
511
666
  createdAt: new Date().toISOString(),
512
667
  updatedAt: new Date().toISOString(),
@@ -514,12 +669,12 @@ export function registerRentTools(server, cfg, walletCtx) {
514
669
  putSpend(record);
515
670
  const done = await fundAndIndex(record);
516
671
  const polled = await pollPosted(done.taskHash, waitSeconds ?? 45);
517
- return ok({ ...done, ...polled });
672
+ return ok({ ...done, escrowed: { amount: price, amountRaw: priceRaw.toString(), currency: s.symbol }, ...polled });
518
673
  }
519
674
  catch (err) {
520
675
  const code = err.code;
521
676
  if (code === 'NOT_TASK_AGENT') {
522
- 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.');
677
+ return fail(code, 'The API key\'s owner wallet does not match the wallet that funded escrow. When this process signs (0G, Arc), mint an sk_ key while signed in with the BLINDMARKET_PRIVATE_KEY wallet; on a relay chain (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.');
523
678
  }
524
679
  return fail(code ?? 'RENT_FAILED', err.message);
525
680
  }
@@ -527,7 +682,7 @@ export function registerRentTools(server, cfg, walletCtx) {
527
682
  // ── post_task ─────────────────────────────────────────────────────────────
528
683
  server.registerTool('post_task', {
529
684
  title: 'Post a Task to the Open Market',
530
- 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.',
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.',
531
686
  inputSchema: {
532
687
  instructions: z.string().min(1).max(100_000).describe('The task brief'),
533
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'),
@@ -541,10 +696,7 @@ export function registerRentTools(server, cfg, walletCtx) {
541
696
  },
542
697
  annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
543
698
  }, async ({ instructions, amount, amount0G, idempotencyKey, capabilities, durationSeconds, privacy, confirm, quoteId }) => {
544
- const f = await requireFunding();
545
- if ('error' in f)
546
- return f.error;
547
- const { s, payFrom } = f;
699
+ // Checked before settlement: a funded spend finishes without it.
548
700
  const existing = getSpend(idempotencyKey);
549
701
  if (existing) {
550
702
  if (existing.stage === 'indexed') {
@@ -557,7 +709,11 @@ export function registerRentTools(server, cfg, walletCtx) {
557
709
  return fail(err.code ?? 'RESUME_FAILED', err.message);
558
710
  }
559
711
  }
560
- const offPosting = notPostingChain(s);
712
+ const f = await requireFunding();
713
+ if ('error' in f)
714
+ return f.error;
715
+ const { s, payFrom } = f;
716
+ const offPosting = await notPostingChain(s);
561
717
  if (offPosting)
562
718
  return fail(offPosting.code, offPosting.message);
563
719
  const isPublic = privacy === 'public';
@@ -572,8 +728,19 @@ export function registerRentTools(server, cfg, walletCtx) {
572
728
  catch {
573
729
  return fail('AMOUNT_INVALID', `"${amountStr}" is not a valid ${s.symbol} amount — at most ${s.decimals} decimal places.`);
574
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
+ };
575
742
  if (!confirm) {
576
- const quote = createQuote('post', { amount: amountStr, currency: s.symbol });
743
+ const quote = createQuote('post', { amount: amountStr, currency: s.symbol }, spend);
577
744
  return ok({
578
745
  quote: {
579
746
  escrow: amountStr,
@@ -588,9 +755,9 @@ export function registerRentTools(server, cfg, walletCtx) {
588
755
  next: `Re-call post_task with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to execute this spend.`,
589
756
  });
590
757
  }
591
- if (!quoteId || !consumeQuote(quoteId, 'post')) {
592
- 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)');
593
- }
758
+ const check = consumeQuote(quoteId, 'post', spend);
759
+ if (!check.ok)
760
+ return quoteRefused(check, 'post_task');
594
761
  try {
595
762
  const plaintext = Buffer.from(instructions, 'utf8');
596
763
  let blobB64;
@@ -637,19 +804,26 @@ export function registerRentTools(server, cfg, walletCtx) {
637
804
  requiredCapabilities: capabilities ?? [],
638
805
  amountWei: amountWei.toString(),
639
806
  settlement: s.mode,
640
- token: s.payment === 'relay-erc20' ? s.token.address : ZERO_TOKEN,
641
- durationSecs: durationSeconds ?? 86400,
807
+ chainId: s.chainId,
808
+ token: isErc20Settlement(s) ? s.token.address : ZERO_TOKEN,
809
+ durationSecs,
642
810
  createdAt: new Date().toISOString(),
643
811
  updatedAt: new Date().toISOString(),
644
812
  };
645
813
  putSpend(record);
646
814
  const done = await fundAndIndex(record);
647
- 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
+ });
648
822
  }
649
823
  catch (err) {
650
824
  const code = err.code;
651
825
  if (code === 'NOT_TASK_AGENT') {
652
- 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.');
826
+ return fail(code, 'The API key\'s owner wallet does not match the wallet that funded escrow. When this process signs (0G, Arc), mint an sk_ key while signed in with the BLINDMARKET_PRIVATE_KEY wallet; on a relay chain (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.');
653
827
  }
654
828
  return fail(code ?? 'POST_FAILED', err.message);
655
829
  }
@@ -675,6 +849,11 @@ export function registerRentTools(server, cfg, walletCtx) {
675
849
  // Disputed (6) → claimTimeout, but only once
676
850
  // DISPUTE_WINDOW has elapsed
677
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
+ //
678
857
  // So the quote step reads the live status first and refuses a path the
679
858
  // contract would reject, rather than letting the caller burn gas to find out.
680
859
  //
@@ -711,7 +890,7 @@ export function registerRentTools(server, cfg, walletCtx) {
711
890
  e.code = `TASK_NOT_ON_${s.chain.toUpperCase().replace(/-/g, '_')}`;
712
891
  return e;
713
892
  };
714
- if (s.payment !== 'relay-erc20') {
893
+ if (!isErc20Settlement(s)) {
715
894
  const detail = await api('GET', `/api/v1/tasks/${encodeURIComponent(task)}`);
716
895
  if (detail.chain && detail.chain !== s.chain)
717
896
  throw onOtherChain(detail.taskId, detail.chain);
@@ -763,18 +942,30 @@ export function registerRentTools(server, cfg, walletCtx) {
763
942
  function refundAmount(detail) {
764
943
  return formatUnits(BigInt(detail.amount), detail.decimals ?? 18);
765
944
  }
766
- /** 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
767
958
  * state rather than a receipt is what makes a relayed user-op decidable
768
959
  * (there is no receipt to poll), and it is a cheap truth check for the
769
- * local-signing path too. */
770
- async function waitCancelled(s, taskId) {
960
+ * local-signing path too. Resolves to the status it read. */
961
+ async function waitLanded(s, taskId, settled) {
771
962
  for (let i = 0; i < 30; i++) {
772
963
  const detail = await loadTask(s, String(taskId)).catch(() => null);
773
- if (detail && Number(detail.status) === 5)
774
- return;
964
+ if (detail && settled.includes(Number(detail.status)))
965
+ return Number(detail.status);
775
966
  await new Promise((r) => setTimeout(r, 3000));
776
967
  }
777
- 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`);
778
969
  e.code = 'REFUND_PENDING';
779
970
  throw e;
780
971
  }
@@ -788,20 +979,36 @@ export function registerRentTools(server, cfg, walletCtx) {
788
979
  const taskId = record.taskId;
789
980
  let { txHash } = record;
790
981
  if (record.settlement && record.settlement !== s.mode) {
791
- 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`);
982
+ const e = new Error(`refund ${record.idempotencyKey} started on ${record.settlement} but this process now settles on ${s.mode} — set BLINDMARKET_SETTLEMENT=${record.settlement} to finish it, or finish it from the web app`);
983
+ e.code = 'SETTLEMENT_CHANGED';
984
+ throw e;
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}.`));
792
995
  e.code = 'SETTLEMENT_CHANGED';
793
996
  throw e;
794
997
  }
795
998
  if (record.stage === 'created') {
796
999
  const route = record.kind === 'cancel' ? 'cancel' : 'timeout';
797
- const { unsignedTx } = await api('POST', `/api/v1/tasks/${taskId}/${route}`);
1000
+ // Task ids repeat across chains: name this one (the backend knows
1001
+ // 'base' and 'arc'; a 0G task is resolved by ownership as before).
1002
+ const { unsignedTx } = await api('POST', `/api/v1/tasks/${taskId}/${route}`, isErc20Settlement(s) ? { chain: s.mode } : undefined);
798
1003
  // The backend resolves the chain that holds the task and builds for it;
799
1004
  // the tx carries no chainId. If that chain is not the one this mode
800
1005
  // broadcasts on, stop here — relaying a 0G refund onto another chain
801
1006
  // lands on an address with no escrow and burns the gas.
802
1007
  await verifyTarget(s, unsignedTx.to, `${route}Task`);
803
- if (s.payment === 'relay-erc20') {
804
- const { hash, isUserOp, gas } = await relaySend(s, { to: unsignedTx.to, data: unsignedTx.data });
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`);
1010
+ if (isErc20Settlement(s)) {
1011
+ const { hash, isUserOp, gas } = await sendErc20(s, { to: unsignedTx.to, data: unsignedTx.data });
805
1012
  // Persist BEFORE waiting, same reasoning as the 0G branch below.
806
1013
  updateSpend(record.idempotencyKey, { stage: 'sent', txHash: hash, isUserOp, gas });
807
1014
  record.gas = gas;
@@ -824,7 +1031,7 @@ export function registerRentTools(server, cfg, walletCtx) {
824
1031
  }
825
1032
  }
826
1033
  else if (txHash) {
827
- if (s.payment === 'relay-erc20')
1034
+ if (isErc20Settlement(s))
828
1035
  await waitRelayed(s, txHash, record.isUserOp ?? false);
829
1036
  else
830
1037
  await walletCtx.provider.waitForTransaction(txHash);
@@ -832,9 +1039,33 @@ export function registerRentTools(server, cfg, walletCtx) {
832
1039
  else {
833
1040
  throw new Error(`Spend record ${record.idempotencyKey} is at stage '${record.stage}' with no txHash — cannot resume safely`);
834
1041
  }
835
- await waitCancelled(s, taskId);
836
- updateSpend(record.idempotencyKey, { stage: 'confirmed' });
837
- return { taskId, txHash: txHash, gas: record.gas };
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 };
1048
+ }
1049
+ /** Tell the backend the refund landed (POST /tasks/:id/confirm-tx): it
1050
+ * checks the receipt and takes the task off the market, which otherwise
1051
+ * keeps listing it as open until its deadline. Best effort, since the
1052
+ * money has already moved. A relayed user-op hash has no receipt to check. */
1053
+ async function confirmRefund(s, taskId, txHash, isUserOp) {
1054
+ if (isUserOp)
1055
+ return false;
1056
+ for (let attempt = 1; attempt <= 3; attempt++) {
1057
+ try {
1058
+ await api('POST', `/api/v1/tasks/${taskId}/confirm-tx`, { txHash, ...(isErc20Settlement(s) ? { chain: s.mode } : {}) });
1059
+ return true;
1060
+ }
1061
+ catch (err) {
1062
+ // The backend's RPC can lag the receipt this side just saw.
1063
+ if (err.code !== 'NOT_CONFIRMED' || attempt === 3)
1064
+ return false;
1065
+ await new Promise((r) => setTimeout(r, 3000));
1066
+ }
1067
+ }
1068
+ return false;
838
1069
  }
839
1070
  /** Shared resume arm: an idempotencyKey that has already moved. The kind
840
1071
  * guard matters — the ledger is one namespace shared with rent/post, and
@@ -849,7 +1080,10 @@ export function registerRentTools(server, cfg, walletCtx) {
849
1080
  resumed: true,
850
1081
  taskId: existing.taskId,
851
1082
  txHash: existing.txHash,
852
- 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.',
853
1087
  });
854
1088
  }
855
1089
  try {
@@ -891,8 +1125,11 @@ export function registerRentTools(server, cfg, walletCtx) {
891
1125
  : ' The escrow is already settled — nothing to reclaim.';
892
1126
  return fail('WRONG_REFUND_PATH', `Task ${detail.taskId} is ${statusName(status)}, and cancelTask only accepts Funded tasks.${alt}`);
893
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);
894
1131
  if (!confirm) {
895
- const quote = createQuote('cancel', { taskId: detail.taskId });
1132
+ const quote = createQuote('cancel', { taskId: detail.taskId }, spend);
896
1133
  return ok({
897
1134
  quote: {
898
1135
  action: 'cancelTask',
@@ -906,15 +1143,16 @@ export function registerRentTools(server, cfg, walletCtx) {
906
1143
  next: `Re-call cancel_task with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to send it.`,
907
1144
  });
908
1145
  }
909
- if (!quoteId || !consumeQuote(quoteId, 'cancel')) {
910
- 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)');
911
- }
1146
+ const check = consumeQuote(quoteId, 'cancel', spend);
1147
+ if (!check.ok)
1148
+ return quoteRefused(check, 'cancel_task');
912
1149
  try {
913
1150
  const record = {
914
1151
  idempotencyKey,
915
1152
  kind: 'cancel',
916
1153
  stage: 'created',
917
1154
  settlement: s.mode,
1155
+ chainId: s.chainId,
918
1156
  taskId: Number(detail.taskId),
919
1157
  taskHash: detail.taskHash,
920
1158
  amountWei: detail.amount,
@@ -931,7 +1169,7 @@ export function registerRentTools(server, cfg, walletCtx) {
931
1169
  });
932
1170
  server.registerTool('claim_timeout', {
933
1171
  title: 'Reclaim Escrow After the Deadline',
934
- 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.',
935
1173
  inputSchema: {
936
1174
  task: z.string().min(1).describe('Task id (e.g. "51") or the 0x task hash returned by post_task'),
937
1175
  idempotencyKey: z.string().min(8).max(128).describe('Unique key for this refund — reuse it on retries'),
@@ -966,8 +1204,9 @@ export function registerRentTools(server, cfg, walletCtx) {
966
1204
  if (now < deadline) {
967
1205
  return fail('DEADLINE_NOT_REACHED', `Task ${detail.taskId} is still live until ${new Date(Number(deadline) * 1000).toISOString()} — claimTimeout reverts before then.`);
968
1206
  }
1207
+ const spend = refundFields(s, payFrom, idempotencyKey, detail);
969
1208
  if (!confirm) {
970
- const quote = createQuote('timeout', { taskId: detail.taskId });
1209
+ const quote = createQuote('timeout', { taskId: detail.taskId }, spend);
971
1210
  return ok({
972
1211
  quote: {
973
1212
  action: 'claimTimeout',
@@ -979,22 +1218,26 @@ export function registerRentTools(server, cfg, walletCtx) {
979
1218
  settlement: s.mode,
980
1219
  // DISPUTE_WINDOW is enforced on-chain and disputedAt is not exposed
981
1220
  // here, so a disputed task can still revert after this quote.
982
- ...(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." } : {}),
983
1224
  quoteId: quote.quoteId,
984
1225
  },
985
1226
  next: `Re-call claim_timeout with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to send it.`,
986
1227
  });
987
1228
  }
988
- if (!quoteId || !consumeQuote(quoteId, 'timeout')) {
989
- 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)');
990
- }
1229
+ const check = consumeQuote(quoteId, 'timeout', spend);
1230
+ if (!check.ok)
1231
+ return quoteRefused(check, 'claim_timeout');
991
1232
  try {
992
1233
  const record = {
993
1234
  idempotencyKey,
994
1235
  kind: 'timeout',
995
1236
  stage: 'created',
996
1237
  settlement: s.mode,
1238
+ chainId: s.chainId,
997
1239
  taskId: Number(detail.taskId),
1240
+ fromStatus: status,
998
1241
  taskHash: detail.taskHash,
999
1242
  amountWei: detail.amount,
1000
1243
  createdAt: new Date().toISOString(),
@@ -1002,6 +1245,9 @@ export function registerRentTools(server, cfg, walletCtx) {
1002
1245
  };
1003
1246
  putSpend(record);
1004
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
+ }
1005
1251
  return ok({ ...done, refunded: refundAmount(detail), hint: 'Escrow returned to the posting wallet.' });
1006
1252
  }
1007
1253
  catch (err) {
@@ -1034,7 +1280,7 @@ export function registerRentTools(server, cfg, walletCtx) {
1034
1280
  title: 'Fetch a Task Brief',
1035
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).",
1036
1282
  inputSchema: {
1037
- 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)'),
1038
1284
  wrappedKey: z.string().optional().describe('ECIES-wrapped AES key from accept_task (hex, no 0x). Required for private briefs.'),
1039
1285
  },
1040
1286
  annotations: { readOnlyHint: true, openWorldHint: true },
@@ -1152,12 +1398,33 @@ export function registerRentTools(server, cfg, walletCtx) {
1152
1398
  }
1153
1399
  const tx = sub.unsignedSubmitEvidence;
1154
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
+ }
1155
1412
  if (s.payment === 'relay-erc20') {
1156
1413
  const sent = await relaySend(s, { to: tx.to, data: tx.data });
1157
1414
  submitTxHash = sent.hash;
1158
1415
  gas = sent.gas;
1159
1416
  await waitRelayed(s, sent.hash, sent.isUserOp);
1160
1417
  }
1418
+ else if (s.payment === 'local-erc20') {
1419
+ // Signed locally over this chain's RPC, like the 0G branch below
1420
+ // but with gas estimated: a revert fails here, unpaid.
1421
+ if (tx.from && tx.from.toLowerCase() !== s.payFrom.toLowerCase()) {
1422
+ return fail('WALLET_MISMATCH', `The backend assigned this task to ${tx.from} (the wallet behind BLINDMARKET_API_KEY), but BLINDMARKET_PRIVATE_KEY is ${s.payFrom}. submitEvidence is worker-only — set the private key of ${tx.from}.`);
1423
+ }
1424
+ const sent = await sendErc20(s, { to: tx.to, data: tx.data });
1425
+ submitTxHash = sent.hash;
1426
+ await waitRelayed(s, sent.hash, false);
1427
+ }
1161
1428
  else {
1162
1429
  // submitEvidence is onlyWorker: the backend built the tx for the
1163
1430
  // API key's wallet (tx.from). If BLINDMARKET_PRIVATE_KEY is a
@@ -1167,12 +1434,13 @@ export function registerRentTools(server, cfg, walletCtx) {
1167
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}.`);
1168
1435
  }
1169
1436
  // 0G: sign locally, exactly as fundAndIndex does for createTask.
1170
- // The backend pins chainId onto the tx; ethers refuses to send it
1171
- // if this wallet's provider is on a different network — the guard
1172
- // 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.
1173
1441
  const tx0g = await walletCtx.wallet.sendTransaction({
1174
1442
  to: tx.to, data: tx.data, gasLimit: GAS_LIMIT,
1175
- ...(tx.chainId !== undefined ? { chainId: tx.chainId } : {}),
1443
+ ...(pinned !== undefined ? { chainId: pinned } : {}),
1176
1444
  });
1177
1445
  submitTxHash = tx0g.hash;
1178
1446
  await tx0g.wait();
@@ -1210,6 +1478,278 @@ export function registerRentTools(server, cfg, walletCtx) {
1210
1478
  return fail(err.code ?? 'COMPLETE_FAILED', err.message);
1211
1479
  }
1212
1480
  });
1481
+ // ── deploy_agent ──────────────────────────────────────────────────────────
1482
+ /** The env var each provider's API key is read from, so the key never passes through the conversation. */
1483
+ const PROVIDER_KEY_ENV = {
1484
+ openai: 'OPENAI_API_KEY',
1485
+ anthropic: 'ANTHROPIC_API_KEY',
1486
+ groq: 'GROQ_API_KEY',
1487
+ gemini: 'GEMINI_API_KEY',
1488
+ };
1489
+ const coded = (code, message) => Object.assign(new Error(message), { code });
1490
+ /** The local wallet on `chain`, over BLINDMARKET_<CHAIN>_RPC_URL (else a
1491
+ * public RPC for that chain id), checked against the chain id the backend
1492
+ * names, and against `expectChainId` (the fee terms') when given. */
1493
+ async function walletOn(chain, expectChainId) {
1494
+ const res = await fetch(`${cfg.apiBase}/health/bridge`, { signal: AbortSignal.timeout(30_000) });
1495
+ const bridge = await res.json().catch(() => ({}));
1496
+ const entry = (bridge?.data ?? bridge)?.chains?.find((c) => c.chain === chain);
1497
+ const chainId = Number(entry?.chainId);
1498
+ if (!Number.isInteger(chainId) || chainId <= 0)
1499
+ throw coded('SETTLEMENT_UNKNOWN', `The backend lists no chain id for ${chain}.`);
1500
+ if (expectChainId !== undefined && expectChainId !== chainId) {
1501
+ throw coded('SETTLEMENT_UNKNOWN', `The deploy fee is on chain ${expectChainId} but the backend lists ${chain} as chain ${chainId}. Nothing was paid.`);
1502
+ }
1503
+ const envName = rpcEnvName(chain);
1504
+ const rpcUrl = rpcFor(chain, chainId, process.env);
1505
+ if (!rpcUrl)
1506
+ throw coded('RPC_UNKNOWN', `No RPC known for ${chain} (chainId ${chainId}) — set ${envName}.`);
1507
+ const provider = new JsonRpcProvider(rpcUrl, chainId, { staticNetwork: true });
1508
+ const served = Number(BigInt(await provider.send('eth_chainId', [])));
1509
+ if (served !== chainId)
1510
+ throw coded('WRONG_RPC', `${envName} serves chain ${served}, not ${chain} (${chainId}). Nothing was paid.`);
1511
+ return walletCtx.wallet.connect(provider);
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
+ }
1541
+ server.registerTool('deploy_agent', {
1542
+ title: 'Deploy a Hosted Agent',
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.",
1544
+ inputSchema: {
1545
+ name: z.string().min(1).max(80).describe('Agent name'),
1546
+ instructions: z.string().min(1).max(100_000).describe("The agent's instructions: what it does and how"),
1547
+ provider: z.enum(['openai', 'anthropic', 'groq', 'gemini', '0g-compute']).describe("LLM provider. '0g-compute' needs no API key: inference is billed to the agent's own wallet"),
1548
+ model: z.string().min(1).describe('Model id, e.g. gpt-4o-mini or claude-sonnet-4-5'),
1549
+ skillSlugs: z.array(z.string()).max(10).optional().describe('Public skills to install at deploy, by slug'),
1550
+ idempotencyKey: z.string().min(8).max(128).describe('Unique key for this deploy — reuse it on retries'),
1551
+ confirm: z.boolean().optional().describe('Set true (with quoteId) to pay the fee and deploy'),
1552
+ quoteId: z.string().optional().describe('From the quote step'),
1553
+ },
1554
+ annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
1555
+ }, async ({ name, instructions, provider, model, skillSlugs, idempotencyKey, confirm, quoteId }) => {
1556
+ if (!walletCtx) {
1557
+ return fail('NO_WALLET', 'deploy_agent pays the fee from, and encrypts the agent key to, the local wallet — set BLINDMARKET_PRIVATE_KEY to the key of the wallet that owns BLINDMARKET_API_KEY.');
1558
+ }
1559
+ let apiKey = '';
1560
+ if (provider !== '0g-compute') {
1561
+ const envName = PROVIDER_KEY_ENV[provider];
1562
+ apiKey = process.env[envName] ?? '';
1563
+ if (!apiKey)
1564
+ return fail('PROVIDER_KEY_MISSING', `Set ${envName} in this server's environment: the agent calls ${provider} with it. It is read there so it never passes through the conversation.`);
1565
+ }
1566
+ const body = {
1567
+ name, instructions, provider, model, apiKey,
1568
+ capabilities: [],
1569
+ skillSlugs: skillSlugs ?? [],
1570
+ // The agent's private key is encrypted to the local wallet's key.
1571
+ ownerPublicKey: derivePublicKeyHex(walletCtx.wallet.privateKey),
1572
+ };
1573
+ const summary = (a) => ({ agentId: a.id, name: a.name, walletAddress: a.walletAddress, started: a.started === true });
1574
+ /** POST /agents/deploy, asking again while the backend has not seen the fee confirm. */
1575
+ const deploy = async (feeTxHash) => {
1576
+ for (let attempt = 1;; attempt++) {
1577
+ try {
1578
+ return await api('POST', '/api/v1/agents/deploy', feeTxHash ? { ...body, feeTxHash } : body);
1579
+ }
1580
+ catch (err) {
1581
+ if (err.code !== 'DEPLOY_FEE_NOT_FOUND' || attempt >= 3)
1582
+ throw err;
1583
+ await new Promise((r) => setTimeout(r, Number(process.env.BLINDMARKET_DEPLOY_POLL_MS ?? 5000)));
1584
+ }
1585
+ }
1586
+ };
1587
+ /** The deploy's own checks, run before anything is quoted or paid. A
1588
+ * backend without the route (404) is not checked here; the deploy
1589
+ * still checks before it takes the fee. */
1590
+ const validate = async () => {
1591
+ try {
1592
+ await api('POST', '/api/v1/agents/deploy/validate', body);
1593
+ return null;
1594
+ }
1595
+ catch (err) {
1596
+ const code = err.code;
1597
+ if (code === undefined && /failed: 404$/.test(err.message))
1598
+ return null;
1599
+ return fail(code ?? 'DEPLOY_INVALID', `${err.message}. Nothing was paid.`);
1600
+ }
1601
+ };
1602
+ const paidNote = () => {
1603
+ const rec = getSpend(idempotencyKey);
1604
+ return rec?.stage === 'sent' && rec.txHash
1605
+ ? ` The fee is paid (${rec.txHash}): retry with the SAME idempotencyKey to deploy without paying again.`
1606
+ : '';
1607
+ };
1608
+ const existing = getSpend(idempotencyKey);
1609
+ if (existing && existing.kind !== 'deploy') {
1610
+ return fail('IDEMPOTENCY_KEY_IN_USE', `idempotencyKey ${idempotencyKey} belongs to a ${existing.kind} spend — use a new key for this deploy.`);
1611
+ }
1612
+ if (existing?.stage === 'confirmed') {
1613
+ return ok({ resumed: true, agentId: existing.agentId, feeTxHash: existing.txHash, hint: 'Already deployed with this idempotencyKey.' });
1614
+ }
1615
+ if (existing?.stage === 'sent' && existing.txHash) {
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
+ }
1625
+ try {
1626
+ const agent = await deploy(existing.txHash);
1627
+ updateSpend(idempotencyKey, { stage: 'confirmed', agentId: agent.id });
1628
+ return ok({ resumed: true, ...summary(agent), feeTxHash: existing.txHash });
1629
+ }
1630
+ catch (err) {
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()}`);
1636
+ }
1637
+ }
1638
+ let terms;
1639
+ try {
1640
+ terms = await api('GET', '/api/v1/agents/deploy-fee');
1641
+ }
1642
+ catch (err) {
1643
+ return fail(err.code ?? 'DEPLOY_FEE_UNKNOWN', err.message);
1644
+ }
1645
+ if (terms.required && terms.method !== 'transfer') {
1646
+ return fail('UNSUPPORTED_FEE_METHOD', 'This backend takes the deploy fee through AgentFactory only, which this server does not pay. Deploy from the web app.');
1647
+ }
1648
+ const fee = terms.required ? terms : null;
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
+ };
1668
+ if (!confirm) {
1669
+ const invalid = await validate();
1670
+ if (invalid)
1671
+ return invalid;
1672
+ let walletBalance;
1673
+ if (fee) {
1674
+ try {
1675
+ const w = await walletOn(fee.chain, fee.chainId);
1676
+ const bal = await new Contract(fee.token, ['function balanceOf(address) view returns (uint256)'], w).balanceOf(w.address);
1677
+ walletBalance = formatUnits(bal, fee.decimals);
1678
+ }
1679
+ catch (err) {
1680
+ if (['RPC_UNKNOWN', 'WRONG_RPC', 'SETTLEMENT_UNKNOWN'].includes(err.code ?? '')) {
1681
+ return fail(err.code, err.message);
1682
+ }
1683
+ }
1684
+ }
1685
+ const quote = createQuote('deploy', { name, provider, model, fee: feeText }, spend);
1686
+ return ok({
1687
+ quote: {
1688
+ agent: { name, provider, model, skills: skillSlugs ?? [] },
1689
+ fee: feeText,
1690
+ chain: fee?.chain,
1691
+ payTo: fee?.recipient,
1692
+ payFrom: walletCtx.wallet.address,
1693
+ walletBalance,
1694
+ quoteId: quote.quoteId,
1695
+ },
1696
+ next: `Re-call deploy_agent with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to ${fee ? 'pay the fee and ' : ''}deploy.`,
1697
+ });
1698
+ }
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);
1705
+ }
1706
+ try {
1707
+ const now = new Date().toISOString();
1708
+ let feeTxHash;
1709
+ if (fee) {
1710
+ // The backend counts a fee only from the API key's owner: check before paying.
1711
+ const { address: owner } = await api('GET', '/api/v1/api-keys/whoami');
1712
+ if (String(owner).toLowerCase() !== walletCtx.wallet.address.toLowerCase()) {
1713
+ return fail('OWNER_MISMATCH', `BLINDMARKET_API_KEY belongs to ${owner} but BLINDMARKET_PRIVATE_KEY is ${walletCtx.wallet.address}. Nothing was paid: the backend counts a deploy fee only from the API key's owner.`);
1714
+ }
1715
+ const invalid = await validate();
1716
+ if (invalid)
1717
+ return invalid;
1718
+ const w = await walletOn(fee.chain, fee.chainId);
1719
+ putSpend({ idempotencyKey, kind: 'deploy', stage: 'created', settlement: fee.chain, chainId: fee.chainId, token: fee.token, amountWei: fee.amountRaw, createdAt: now, updatedAt: now });
1720
+ const data = new Interface(['function transfer(address to, uint256 amount) returns (bool)'])
1721
+ .encodeFunctionData('transfer', [fee.recipient, BigInt(fee.amountRaw)]);
1722
+ const tx = await w.sendTransaction({ to: fee.token, data });
1723
+ // Recorded the moment it is broadcast: a retry resumes from here and never pays twice.
1724
+ updateSpend(idempotencyKey, { stage: 'sent', txHash: tx.hash });
1725
+ try {
1726
+ await tx.wait();
1727
+ }
1728
+ catch (err) {
1729
+ if (err.code === 'CALL_EXCEPTION') {
1730
+ updateSpend(idempotencyKey, { stage: 'created', txHash: undefined });
1731
+ return fail('FEE_REVERTED', `The fee transfer ${tx.hash} reverted, so nothing was paid. Check the wallet's USDC on ${fee.chain} and retry.`);
1732
+ }
1733
+ // Not confirmed yet: the backend waits for the receipt itself.
1734
+ }
1735
+ feeTxHash = tx.hash;
1736
+ }
1737
+ else {
1738
+ putSpend({ idempotencyKey, kind: 'deploy', stage: 'created', createdAt: now, updatedAt: now });
1739
+ }
1740
+ const agent = await deploy(feeTxHash);
1741
+ updateSpend(idempotencyKey, { stage: 'confirmed', agentId: agent.id });
1742
+ return ok({
1743
+ ...summary(agent),
1744
+ fee: feeText,
1745
+ feeTxHash,
1746
+ hint: agent.started ? 'The agent is running.' : 'The agent was created but did not start — start it with start_agent.',
1747
+ });
1748
+ }
1749
+ catch (err) {
1750
+ return fail(err.code ?? 'DEPLOY_FAILED', `${err.message}.${paidNote()}`);
1751
+ }
1752
+ });
1213
1753
  return { settlement };
1214
1754
  }
1215
1755
  //# sourceMappingURL=rent.js.map