@blindmarket/mcp-server 0.4.0 → 0.5.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';
2
+ import { Contract, Interface, JsonRpcProvider, formatUnits, parseUnits } from 'ethers';
3
+ import { aesDecrypt, aesEncrypt, derivePublicKeyHex, eciesDecrypt, eciesEncrypt, generateAesKey, sha256Hex } from './crypto.js';
4
4
  import { createQuote, consumeQuote, getSpend, putSpend, updateSpend } from './state.js';
5
- import { createSettlementResolver } from './settlement.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.
@@ -93,11 +93,16 @@ export function registerRentTools(server, cfg, walletCtx) {
93
93
  }
94
94
  return json.data;
95
95
  }
96
- const settlement = createSettlementResolver({ apiBase: cfg.apiBase ?? 'https://api.blindmarket.xyz', api });
96
+ const settlement = createSettlementResolver({
97
+ apiBase: cfg.apiBase ?? 'https://api.blindmarket.xyz',
98
+ api,
99
+ localWallet: walletCtx?.wallet.address,
100
+ });
97
101
  /** How this process pays. On 0G that is the local wallet, which must exist.
98
102
  * On a relay chain (Base) nothing signs locally — the relay signs from the
99
103
  * API key's owner wallet — so a missing BLINDMARKET_PRIVATE_KEY is not an
100
- * error there. */
104
+ * error there. On an ERC-20 chain without a relay (Arc) discovery only
105
+ * succeeds with the local wallet, already checked to be the key's owner. */
101
106
  async function requireFunding() {
102
107
  let s;
103
108
  try {
@@ -106,7 +111,7 @@ export function registerRentTools(server, cfg, walletCtx) {
106
111
  catch (err) {
107
112
  return { error: fail(err.code ?? 'SETTLEMENT_UNKNOWN', err.message) };
108
113
  }
109
- if (s.payment === 'relay-erc20')
114
+ if (isErc20Settlement(s))
110
115
  return { s, payFrom: s.payFrom };
111
116
  if (!walletCtx) {
112
117
  return { error: fail('NO_WALLET', 'Spending on 0G needs a local funding wallet — set BLINDMARKET_PRIVATE_KEY (see wallet_status)') };
@@ -124,10 +129,23 @@ export function registerRentTools(server, cfg, walletCtx) {
124
129
  /** A new escrow is funded where POST /api/v1/tasks builds: the backend's
125
130
  * posting chain. A process forced onto another chain (to finish or refund
126
131
  * tasks already there) cannot post. Unknown on an older backend. */
127
- function notPostingChain(s) {
128
- if (s.postingChain === undefined || s.mode === s.postingChain)
132
+ async function notPostingChain(s) {
133
+ let posting = s.postingChain;
134
+ // Forced to 0G, discovery never asked the backend. Ask now, before a quote:
135
+ // a backend that posts elsewhere refuses the native-0G createTask only
136
+ // after the brief is uploaded and its hash claimed.
137
+ if (posting === undefined && s.mode === '0g') {
138
+ try {
139
+ const res = await fetch(`${cfg.apiBase}/health/settlement`, { signal: AbortSignal.timeout(15_000) });
140
+ const json = await res.json();
141
+ if (json?.success && typeof json.data?.postingChain === 'string')
142
+ posting = json.data.postingChain;
143
+ }
144
+ catch { /* an older backend or a blip: keep the old behaviour, the escrow check still guards the send */ }
145
+ }
146
+ if (posting === undefined || s.mode === posting)
129
147
  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.`);
148
+ 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
149
  e.code = 'NOT_POSTING_CHAIN';
132
150
  return e;
133
151
  }
@@ -139,10 +157,15 @@ export function registerRentTools(server, cfg, walletCtx) {
139
157
  function settlementToken(s) {
140
158
  return new Contract(s.token.address, ERC20, s.provider);
141
159
  }
160
+ /** The local wallet on a no-relay ERC-20 chain, over that chain's RPC
161
+ * (checked at discovery to serve the chain the backend names). */
162
+ function localSigner(s) {
163
+ return walletCtx.wallet.connect(s.provider);
164
+ }
142
165
  /** Spendable balance of whoever pays, in the settlement token's units. */
143
166
  async function payFromBalance(s, payFrom) {
144
167
  try {
145
- const raw = s.payment === 'relay-erc20'
168
+ const raw = isErc20Settlement(s)
146
169
  ? await settlementToken(s).balanceOf(payFrom)
147
170
  : await walletCtx.provider.getBalance(payFrom);
148
171
  return formatUnits(raw, s.decimals);
@@ -213,6 +236,20 @@ export function registerRentTools(server, cfg, walletCtx) {
213
236
  // default it applies to this body: user-pays.
214
237
  return { hash: r.hash, isUserOp: r.isUserOp === true, gas: r.gas ?? 'user-pays' };
215
238
  }
239
+ /** Send a tx on an ERC-20 settlement: through the relay, or signed by the
240
+ * local wallet on a chain the relay does not serve. Either way the caller
241
+ * persists the hash before waiting (waitRelayed polls the chain's own RPC
242
+ * for a plain hash, which a local send always is). `nonce` pins a local
243
+ * send right after one this process made: an RPC can answer the next nonce
244
+ * lookup from before the earlier tx landed. */
245
+ async function sendErc20(s, tx, nonce) {
246
+ if (s.payment === 'relay-erc20')
247
+ return relaySend(s, tx);
248
+ // No gasLimit: estimation runs first, so a predictable revert (a stale
249
+ // allowance, a passed deadline) fails here without being mined and paid for.
250
+ const sent = await localSigner(s).sendTransaction({ to: tx.to, data: tx.data, ...(nonce !== undefined ? { nonce } : {}) });
251
+ return { hash: sent.hash, isUserOp: false, nonce: sent.nonce };
252
+ }
216
253
  /** Wait for a relayed tx to land. A plain hash can be polled for its receipt;
217
254
  * a user-op hash cannot (getTransactionReceipt is always null for it), so
218
255
  * that case returns at once and the caller confirms by on-chain STATE —
@@ -224,7 +261,7 @@ export function registerRentTools(server, cfg, walletCtx) {
224
261
  const receipt = await s.provider.getTransactionReceipt(hash).catch(() => null);
225
262
  if (receipt) {
226
263
  if (receipt.status === 0) {
227
- const e = new Error(`relayed tx ${hash} reverted`);
264
+ const e = new Error(`tx ${hash} reverted`);
228
265
  e.code = 'TX_REVERTED';
229
266
  throw e;
230
267
  }
@@ -232,21 +269,26 @@ export function registerRentTools(server, cfg, walletCtx) {
232
269
  }
233
270
  await new Promise((r) => setTimeout(r, 3000));
234
271
  }
235
- const e = new Error(`relayed tx ${hash} not confirmed after 90s — retry with the same idempotencyKey to resume`);
272
+ const e = new Error(`tx ${hash} not confirmed after 90s — retry with the same idempotencyKey to resume`);
236
273
  e.code = 'TX_PENDING';
237
274
  throw e;
238
275
  }
239
- /** Relay chains only: createTask pulls the ERC-20 via transferFrom, so the
276
+ /** ERC-20 chains: createTask pulls the token via transferFrom, so the
240
277
  * escrow needs an allowance first. Confirmed by re-reading allowance()
241
- * rather than by receipt, which is what makes the user-op case decidable. */
278
+ * rather than by receipt, which is what makes the user-op case decidable.
279
+ * Returns the nonce the createTask should use when this call sent a local
280
+ * approve, else undefined. */
242
281
  async function ensureAllowance(s, record) {
243
282
  const need = BigInt(record.amountWei);
244
283
  const token = settlementToken(s);
245
284
  if ((await token.allowance(s.payFrom, s.escrowAddress)) >= need)
246
- return;
285
+ return undefined;
286
+ let nextNonce;
247
287
  const approve = async () => {
248
288
  const data = ERC20.encodeFunctionData('approve', [s.escrowAddress, need]);
249
- const { hash } = await relaySend(s, { to: s.token.address, data });
289
+ const { hash, nonce } = await sendErc20(s, { to: s.token.address, data });
290
+ if (nonce !== undefined)
291
+ nextNonce = nonce + 1;
250
292
  // Persist BEFORE waiting: a crash here must resume into the poll below.
251
293
  updateSpend(record.idempotencyKey, { stage: 'approved', approveTxHash: hash });
252
294
  record.stage = 'approved';
@@ -263,18 +305,18 @@ export function registerRentTools(server, cfg, walletCtx) {
263
305
  if (record.stage === 'created') {
264
306
  await approve();
265
307
  if (await settled())
266
- return;
308
+ return nextNonce;
267
309
  }
268
310
  // Resumed at 'approved' (or the fresh approve never landed): the earlier
269
311
  // approve was dropped or reverted. Sending another is safe — ERC-20
270
312
  // approve SETS the allowance, it does not add — and it is the only way
271
313
  // out of this stage, so do it rather than leave the record stuck.
272
314
  if (await settled())
273
- return;
315
+ return nextNonce;
274
316
  await approve();
275
317
  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`);
318
+ return nextNonce;
319
+ 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
320
  e.code = 'APPROVE_PENDING';
279
321
  throw e;
280
322
  }
@@ -288,25 +330,28 @@ export function registerRentTools(server, cfg, walletCtx) {
288
330
  * both through the Privy relay with no local signing at all. The
289
331
  * backend picks the escrow; we only check it is the one we approved. */
290
332
  async function fundAndIndex(record) {
291
- const s = await settlement();
292
333
  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
- }
334
+ // Nothing left to sign once the escrow is funded: /a2a/tasks/index finds
335
+ // the receipt on whichever chain holds it. So a funded spend finishes
336
+ // even when settlement cannot be discovered right now, or has moved.
301
337
  if (record.stage === 'created' || record.stage === 'approved') {
302
- const refused = notPostingChain(s);
338
+ const s = await settlement();
339
+ // A record remembers the chain it started on. If the backend flips mode
340
+ // between attempts, re-funding through the other path would double-fund
341
+ // or send native value into a USDC transferFrom — refuse instead.
342
+ if (record.settlement && record.settlement !== s.mode) {
343
+ const e = new Error(`spend ${record.idempotencyKey} started on ${record.settlement} but this process now settles on ${s.mode}. ` +
344
+ `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.`);
345
+ e.code = 'SETTLEMENT_CHANGED';
346
+ throw e;
347
+ }
348
+ const refused = await notPostingChain(s);
303
349
  if (refused)
304
350
  throw refused;
305
- if (s.payment === 'relay-erc20')
306
- await ensureAllowance(s, record);
351
+ const nonce = isErc20Settlement(s) ? await ensureAllowance(s, record) : undefined;
307
352
  const { unsignedTx, chain: builtChain, chainId: builtChainId } = await api('POST', '/api/v1/tasks', {
308
353
  taskHash: record.taskHash,
309
- token: s.payment === 'relay-erc20' ? s.token.address : ZERO_TOKEN,
354
+ token: isErc20Settlement(s) ? s.token.address : ZERO_TOKEN,
310
355
  amount: record.amountWei,
311
356
  locationZone: 'global',
312
357
  duration: String(record.durationSecs ?? 3600),
@@ -329,8 +374,8 @@ export function registerRentTools(server, cfg, walletCtx) {
329
374
  throw e;
330
375
  }
331
376
  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 });
377
+ if (isErc20Settlement(s)) {
378
+ const { hash, isUserOp, gas } = await sendErc20(s, { to: unsignedTx.to, data: unsignedTx.data }, nonce);
334
379
  // Persist BEFORE waiting, same reasoning as the 0G branch below.
335
380
  updateSpend(record.idempotencyKey, { stage: 'funded', txHash: hash, isUserOp, gas });
336
381
  record.gas = gas;
@@ -397,7 +442,7 @@ export function registerRentTools(server, cfg, walletCtx) {
397
442
  // ── rent_service ──────────────────────────────────────────────────────────
398
443
  server.registerTool('rent_service', {
399
444
  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).',
445
+ description: 'Hire a listed agent service for one call: encrypts your prompt locally (unless privacy=public), funds escrow, and pins the task to the provider agent. Escrow is paid on the backend\'s posting chain: USDC on Arc signed by the local wallet (BLINDMARKET_PRIVATE_KEY, which must own BLINDMARKET_API_KEY; gas is also USDC), USDC on Base through the backend relay (no private key needed), or native 0G from the local wallet. wallet_status shows which. TWO-STEP: first call returns a price quote + quoteId; re-call with confirm=true and that quoteId to actually spend. Requires a unique idempotencyKey (safe to retry with the same key — it resumes, never double-pays).',
401
446
  inputSchema: {
402
447
  serviceId: z.number().int().positive().describe('Service id from browse_services / get_service'),
403
448
  prompt: z.string().min(1).max(100_000).describe('What you want the agent to do'),
@@ -409,11 +454,8 @@ export function registerRentTools(server, cfg, walletCtx) {
409
454
  },
410
455
  annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
411
456
  }, 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).
457
+ // Resume path — this key already spent (or partially spent). Checked
458
+ // before settlement: a funded spend finishes without it.
417
459
  const existing = getSpend(idempotencyKey);
418
460
  if (existing) {
419
461
  if (existing.stage === 'indexed') {
@@ -429,7 +471,11 @@ export function registerRentTools(server, cfg, walletCtx) {
429
471
  return fail(err.code ?? 'RESUME_FAILED', err.message);
430
472
  }
431
473
  }
432
- const offPosting = notPostingChain(s);
474
+ const f = await requireFunding();
475
+ if ('error' in f)
476
+ return f.error;
477
+ const { s, payFrom } = f;
478
+ const offPosting = await notPostingChain(s);
433
479
  if (offPosting)
434
480
  return fail(offPosting.code, offPosting.message);
435
481
  const service = await api('GET', `/api/v1/marketplace/services/${serviceId}`);
@@ -506,7 +552,7 @@ export function registerRentTools(server, cfg, walletCtx) {
506
552
  requiredCapabilities: [],
507
553
  amountWei: String(service.price_raw),
508
554
  settlement: s.mode,
509
- token: s.payment === 'relay-erc20' ? s.token.address : ZERO_TOKEN,
555
+ token: isErc20Settlement(s) ? s.token.address : ZERO_TOKEN,
510
556
  durationSecs: 3600,
511
557
  createdAt: new Date().toISOString(),
512
558
  updatedAt: new Date().toISOString(),
@@ -519,7 +565,7 @@ export function registerRentTools(server, cfg, walletCtx) {
519
565
  catch (err) {
520
566
  const code = err.code;
521
567
  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.');
568
+ 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
569
  }
524
570
  return fail(code ?? 'RENT_FAILED', err.message);
525
571
  }
@@ -527,7 +573,7 @@ export function registerRentTools(server, cfg, walletCtx) {
527
573
  // ── post_task ─────────────────────────────────────────────────────────────
528
574
  server.registerTool('post_task', {
529
575
  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.',
576
+ description: 'Post a task any matching agent can pick up: encrypts the brief locally and wraps its key to every registered matching executor (or posts it in plaintext with privacy=public), then funds escrow. Escrow is paid on the backend\'s posting chain: USDC on Arc signed by the local wallet (BLINDMARKET_PRIVATE_KEY, which must own BLINDMARKET_API_KEY; gas is also USDC), USDC on Base through the backend relay (no private key needed), or native 0G from the local wallet. wallet_status shows which. TWO-STEP quote/confirm like rent_service; requires a unique idempotencyKey.',
531
577
  inputSchema: {
532
578
  instructions: z.string().min(1).max(100_000).describe('The task brief'),
533
579
  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 +587,7 @@ export function registerRentTools(server, cfg, walletCtx) {
541
587
  },
542
588
  annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
543
589
  }, 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;
590
+ // Checked before settlement: a funded spend finishes without it.
548
591
  const existing = getSpend(idempotencyKey);
549
592
  if (existing) {
550
593
  if (existing.stage === 'indexed') {
@@ -557,7 +600,11 @@ export function registerRentTools(server, cfg, walletCtx) {
557
600
  return fail(err.code ?? 'RESUME_FAILED', err.message);
558
601
  }
559
602
  }
560
- const offPosting = notPostingChain(s);
603
+ const f = await requireFunding();
604
+ if ('error' in f)
605
+ return f.error;
606
+ const { s, payFrom } = f;
607
+ const offPosting = await notPostingChain(s);
561
608
  if (offPosting)
562
609
  return fail(offPosting.code, offPosting.message);
563
610
  const isPublic = privacy === 'public';
@@ -637,7 +684,7 @@ export function registerRentTools(server, cfg, walletCtx) {
637
684
  requiredCapabilities: capabilities ?? [],
638
685
  amountWei: amountWei.toString(),
639
686
  settlement: s.mode,
640
- token: s.payment === 'relay-erc20' ? s.token.address : ZERO_TOKEN,
687
+ token: isErc20Settlement(s) ? s.token.address : ZERO_TOKEN,
641
688
  durationSecs: durationSeconds ?? 86400,
642
689
  createdAt: new Date().toISOString(),
643
690
  updatedAt: new Date().toISOString(),
@@ -649,7 +696,7 @@ export function registerRentTools(server, cfg, walletCtx) {
649
696
  catch (err) {
650
697
  const code = err.code;
651
698
  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.');
699
+ 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
700
  }
654
701
  return fail(code ?? 'POST_FAILED', err.message);
655
702
  }
@@ -711,7 +758,7 @@ export function registerRentTools(server, cfg, walletCtx) {
711
758
  e.code = `TASK_NOT_ON_${s.chain.toUpperCase().replace(/-/g, '_')}`;
712
759
  return e;
713
760
  };
714
- if (s.payment !== 'relay-erc20') {
761
+ if (!isErc20Settlement(s)) {
715
762
  const detail = await api('GET', `/api/v1/tasks/${encodeURIComponent(task)}`);
716
763
  if (detail.chain && detail.chain !== s.chain)
717
764
  throw onOtherChain(detail.taskId, detail.chain);
@@ -788,20 +835,22 @@ export function registerRentTools(server, cfg, walletCtx) {
788
835
  const taskId = record.taskId;
789
836
  let { txHash } = record;
790
837
  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`);
838
+ 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`);
792
839
  e.code = 'SETTLEMENT_CHANGED';
793
840
  throw e;
794
841
  }
795
842
  if (record.stage === 'created') {
796
843
  const route = record.kind === 'cancel' ? 'cancel' : 'timeout';
797
- const { unsignedTx } = await api('POST', `/api/v1/tasks/${taskId}/${route}`);
844
+ // Task ids repeat across chains: name this one (the backend knows
845
+ // 'base' and 'arc'; a 0G task is resolved by ownership as before).
846
+ const { unsignedTx } = await api('POST', `/api/v1/tasks/${taskId}/${route}`, isErc20Settlement(s) ? { chain: s.mode } : undefined);
798
847
  // The backend resolves the chain that holds the task and builds for it;
799
848
  // the tx carries no chainId. If that chain is not the one this mode
800
849
  // broadcasts on, stop here — relaying a 0G refund onto another chain
801
850
  // lands on an address with no escrow and burns the gas.
802
851
  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 });
852
+ if (isErc20Settlement(s)) {
853
+ const { hash, isUserOp, gas } = await sendErc20(s, { to: unsignedTx.to, data: unsignedTx.data });
805
854
  // Persist BEFORE waiting, same reasoning as the 0G branch below.
806
855
  updateSpend(record.idempotencyKey, { stage: 'sent', txHash: hash, isUserOp, gas });
807
856
  record.gas = gas;
@@ -824,7 +873,7 @@ export function registerRentTools(server, cfg, walletCtx) {
824
873
  }
825
874
  }
826
875
  else if (txHash) {
827
- if (s.payment === 'relay-erc20')
876
+ if (isErc20Settlement(s))
828
877
  await waitRelayed(s, txHash, record.isUserOp ?? false);
829
878
  else
830
879
  await walletCtx.provider.waitForTransaction(txHash);
@@ -834,7 +883,28 @@ export function registerRentTools(server, cfg, walletCtx) {
834
883
  }
835
884
  await waitCancelled(s, taskId);
836
885
  updateSpend(record.idempotencyKey, { stage: 'confirmed' });
837
- return { taskId, txHash: txHash, gas: record.gas };
886
+ return { taskId, txHash: txHash, gas: record.gas, listingClosed: await confirmRefund(s, taskId, txHash, record.isUserOp ?? false) };
887
+ }
888
+ /** Tell the backend the refund landed (POST /tasks/:id/confirm-tx): it
889
+ * checks the receipt and takes the task off the market, which otherwise
890
+ * keeps listing it as open until its deadline. Best effort, since the
891
+ * money has already moved. A relayed user-op hash has no receipt to check. */
892
+ async function confirmRefund(s, taskId, txHash, isUserOp) {
893
+ if (isUserOp)
894
+ return false;
895
+ for (let attempt = 1; attempt <= 3; attempt++) {
896
+ try {
897
+ await api('POST', `/api/v1/tasks/${taskId}/confirm-tx`, { txHash, ...(isErc20Settlement(s) ? { chain: s.mode } : {}) });
898
+ return true;
899
+ }
900
+ catch (err) {
901
+ // The backend's RPC can lag the receipt this side just saw.
902
+ if (err.code !== 'NOT_CONFIRMED' || attempt === 3)
903
+ return false;
904
+ await new Promise((r) => setTimeout(r, 3000));
905
+ }
906
+ }
907
+ return false;
838
908
  }
839
909
  /** Shared resume arm: an idempotencyKey that has already moved. The kind
840
910
  * guard matters — the ledger is one namespace shared with rent/post, and
@@ -1158,6 +1228,16 @@ export function registerRentTools(server, cfg, walletCtx) {
1158
1228
  gas = sent.gas;
1159
1229
  await waitRelayed(s, sent.hash, sent.isUserOp);
1160
1230
  }
1231
+ else if (s.payment === 'local-erc20') {
1232
+ // Signed locally over this chain's RPC, like the 0G branch below
1233
+ // but with gas estimated: a revert fails here, unpaid.
1234
+ if (tx.from && tx.from.toLowerCase() !== s.payFrom.toLowerCase()) {
1235
+ 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}.`);
1236
+ }
1237
+ const sent = await sendErc20(s, { to: tx.to, data: tx.data });
1238
+ submitTxHash = sent.hash;
1239
+ await waitRelayed(s, sent.hash, false);
1240
+ }
1161
1241
  else {
1162
1242
  // submitEvidence is onlyWorker: the backend built the tx for the
1163
1243
  // API key's wallet (tx.from). If BLINDMARKET_PRIVATE_KEY is a
@@ -1210,6 +1290,215 @@ export function registerRentTools(server, cfg, walletCtx) {
1210
1290
  return fail(err.code ?? 'COMPLETE_FAILED', err.message);
1211
1291
  }
1212
1292
  });
1293
+ // ── deploy_agent ──────────────────────────────────────────────────────────
1294
+ /** The env var each provider's API key is read from, so the key never passes through the conversation. */
1295
+ const PROVIDER_KEY_ENV = {
1296
+ openai: 'OPENAI_API_KEY',
1297
+ anthropic: 'ANTHROPIC_API_KEY',
1298
+ groq: 'GROQ_API_KEY',
1299
+ gemini: 'GEMINI_API_KEY',
1300
+ };
1301
+ const coded = (code, message) => Object.assign(new Error(message), { code });
1302
+ /** The local wallet on `chain`, over BLINDMARKET_<CHAIN>_RPC_URL (else a
1303
+ * public RPC for that chain id), checked against the chain id the backend
1304
+ * names, and against `expectChainId` (the fee terms') when given. */
1305
+ async function walletOn(chain, expectChainId) {
1306
+ const res = await fetch(`${cfg.apiBase}/health/bridge`, { signal: AbortSignal.timeout(30_000) });
1307
+ const bridge = await res.json().catch(() => ({}));
1308
+ const entry = (bridge?.data ?? bridge)?.chains?.find((c) => c.chain === chain);
1309
+ const chainId = Number(entry?.chainId);
1310
+ if (!Number.isInteger(chainId) || chainId <= 0)
1311
+ throw coded('SETTLEMENT_UNKNOWN', `The backend lists no chain id for ${chain}.`);
1312
+ if (expectChainId !== undefined && expectChainId !== chainId) {
1313
+ throw coded('SETTLEMENT_UNKNOWN', `The deploy fee is on chain ${expectChainId} but the backend lists ${chain} as chain ${chainId}. Nothing was paid.`);
1314
+ }
1315
+ const envName = rpcEnvName(chain);
1316
+ const rpcUrl = rpcFor(chain, chainId, process.env);
1317
+ if (!rpcUrl)
1318
+ throw coded('RPC_UNKNOWN', `No RPC known for ${chain} (chainId ${chainId}) — set ${envName}.`);
1319
+ const provider = new JsonRpcProvider(rpcUrl, chainId, { staticNetwork: true });
1320
+ const served = Number(BigInt(await provider.send('eth_chainId', [])));
1321
+ if (served !== chainId)
1322
+ throw coded('WRONG_RPC', `${envName} serves chain ${served}, not ${chain} (${chainId}). Nothing was paid.`);
1323
+ return walletCtx.wallet.connect(provider);
1324
+ }
1325
+ server.registerTool('deploy_agent', {
1326
+ title: 'Deploy a Hosted Agent',
1327
+ 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.",
1328
+ inputSchema: {
1329
+ name: z.string().min(1).max(80).describe('Agent name'),
1330
+ instructions: z.string().min(1).max(100_000).describe("The agent's instructions: what it does and how"),
1331
+ 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"),
1332
+ model: z.string().min(1).describe('Model id, e.g. gpt-4o-mini or claude-sonnet-4-5'),
1333
+ skillSlugs: z.array(z.string()).max(10).optional().describe('Public skills to install at deploy, by slug'),
1334
+ idempotencyKey: z.string().min(8).max(128).describe('Unique key for this deploy — reuse it on retries'),
1335
+ confirm: z.boolean().optional().describe('Set true (with quoteId) to pay the fee and deploy'),
1336
+ quoteId: z.string().optional().describe('From the quote step'),
1337
+ },
1338
+ annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
1339
+ }, async ({ name, instructions, provider, model, skillSlugs, idempotencyKey, confirm, quoteId }) => {
1340
+ if (!walletCtx) {
1341
+ 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.');
1342
+ }
1343
+ let apiKey = '';
1344
+ if (provider !== '0g-compute') {
1345
+ const envName = PROVIDER_KEY_ENV[provider];
1346
+ apiKey = process.env[envName] ?? '';
1347
+ if (!apiKey)
1348
+ 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.`);
1349
+ }
1350
+ const body = {
1351
+ name, instructions, provider, model, apiKey,
1352
+ capabilities: [],
1353
+ skillSlugs: skillSlugs ?? [],
1354
+ // The agent's private key is encrypted to the local wallet's key.
1355
+ ownerPublicKey: derivePublicKeyHex(walletCtx.wallet.privateKey),
1356
+ };
1357
+ const summary = (a) => ({ agentId: a.id, name: a.name, walletAddress: a.walletAddress, started: a.started === true });
1358
+ /** POST /agents/deploy, asking again while the backend has not seen the fee confirm. */
1359
+ const deploy = async (feeTxHash) => {
1360
+ for (let attempt = 1;; attempt++) {
1361
+ try {
1362
+ return await api('POST', '/api/v1/agents/deploy', feeTxHash ? { ...body, feeTxHash } : body);
1363
+ }
1364
+ catch (err) {
1365
+ if (err.code !== 'DEPLOY_FEE_NOT_FOUND' || attempt >= 3)
1366
+ throw err;
1367
+ await new Promise((r) => setTimeout(r, Number(process.env.BLINDMARKET_DEPLOY_POLL_MS ?? 5000)));
1368
+ }
1369
+ }
1370
+ };
1371
+ /** The deploy's own checks, run before anything is quoted or paid. A
1372
+ * backend without the route (404) is not checked here; the deploy
1373
+ * still checks before it takes the fee. */
1374
+ const validate = async () => {
1375
+ try {
1376
+ await api('POST', '/api/v1/agents/deploy/validate', body);
1377
+ return null;
1378
+ }
1379
+ catch (err) {
1380
+ const code = err.code;
1381
+ if (code === undefined && /failed: 404$/.test(err.message))
1382
+ return null;
1383
+ return fail(code ?? 'DEPLOY_INVALID', `${err.message}. Nothing was paid.`);
1384
+ }
1385
+ };
1386
+ const paidNote = () => {
1387
+ const rec = getSpend(idempotencyKey);
1388
+ return rec?.stage === 'sent' && rec.txHash
1389
+ ? ` The fee is paid (${rec.txHash}): retry with the SAME idempotencyKey to deploy without paying again.`
1390
+ : '';
1391
+ };
1392
+ const existing = getSpend(idempotencyKey);
1393
+ if (existing && existing.kind !== 'deploy') {
1394
+ return fail('IDEMPOTENCY_KEY_IN_USE', `idempotencyKey ${idempotencyKey} belongs to a ${existing.kind} spend — use a new key for this deploy.`);
1395
+ }
1396
+ if (existing?.stage === 'confirmed') {
1397
+ return ok({ resumed: true, agentId: existing.agentId, feeTxHash: existing.txHash, hint: 'Already deployed with this idempotencyKey.' });
1398
+ }
1399
+ if (existing?.stage === 'sent' && existing.txHash) {
1400
+ // Paid before: finish the deploy with that payment.
1401
+ try {
1402
+ const agent = await deploy(existing.txHash);
1403
+ updateSpend(idempotencyKey, { stage: 'confirmed', agentId: agent.id });
1404
+ return ok({ resumed: true, ...summary(agent), feeTxHash: existing.txHash });
1405
+ }
1406
+ catch (err) {
1407
+ return fail(err.code ?? 'DEPLOY_FAILED', `${err.message}.${paidNote()}`);
1408
+ }
1409
+ }
1410
+ let terms;
1411
+ try {
1412
+ terms = await api('GET', '/api/v1/agents/deploy-fee');
1413
+ }
1414
+ catch (err) {
1415
+ return fail(err.code ?? 'DEPLOY_FEE_UNKNOWN', err.message);
1416
+ }
1417
+ if (terms.required && terms.method !== 'transfer') {
1418
+ 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.');
1419
+ }
1420
+ const fee = terms.required ? terms : null;
1421
+ const feeText = fee ? `${formatUnits(BigInt(fee.amountRaw), fee.decimals).replace(/\.0$/, '')} USDC` : 'none';
1422
+ if (!confirm) {
1423
+ const invalid = await validate();
1424
+ if (invalid)
1425
+ return invalid;
1426
+ let walletBalance;
1427
+ if (fee) {
1428
+ try {
1429
+ const w = await walletOn(fee.chain, fee.chainId);
1430
+ const bal = await new Contract(fee.token, ['function balanceOf(address) view returns (uint256)'], w).balanceOf(w.address);
1431
+ walletBalance = formatUnits(bal, fee.decimals);
1432
+ }
1433
+ catch (err) {
1434
+ if (['RPC_UNKNOWN', 'WRONG_RPC', 'SETTLEMENT_UNKNOWN'].includes(err.code ?? '')) {
1435
+ return fail(err.code, err.message);
1436
+ }
1437
+ }
1438
+ }
1439
+ const quote = createQuote('deploy', { name, provider, model, fee: feeText });
1440
+ return ok({
1441
+ quote: {
1442
+ agent: { name, provider, model, skills: skillSlugs ?? [] },
1443
+ fee: feeText,
1444
+ chain: fee?.chain,
1445
+ payTo: fee?.recipient,
1446
+ payFrom: walletCtx.wallet.address,
1447
+ walletBalance,
1448
+ quoteId: quote.quoteId,
1449
+ },
1450
+ next: `Re-call deploy_agent with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to ${fee ? 'pay the fee and ' : ''}deploy.`,
1451
+ });
1452
+ }
1453
+ if (!quoteId || !consumeQuote(quoteId, 'deploy')) {
1454
+ return fail('QUOTE_REQUIRED', 'Get a quote first (call without confirm), then re-call with confirm=true and the returned quoteId (quotes are single-use and expire after 10 minutes)');
1455
+ }
1456
+ try {
1457
+ const now = new Date().toISOString();
1458
+ let feeTxHash;
1459
+ if (fee) {
1460
+ // The backend counts a fee only from the API key's owner: check before paying.
1461
+ const { address: owner } = await api('GET', '/api/v1/api-keys/whoami');
1462
+ if (String(owner).toLowerCase() !== walletCtx.wallet.address.toLowerCase()) {
1463
+ 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.`);
1464
+ }
1465
+ const invalid = await validate();
1466
+ if (invalid)
1467
+ return invalid;
1468
+ const w = await walletOn(fee.chain, fee.chainId);
1469
+ putSpend({ idempotencyKey, kind: 'deploy', stage: 'created', settlement: fee.chain, token: fee.token, amountWei: fee.amountRaw, createdAt: now, updatedAt: now });
1470
+ const data = new Interface(['function transfer(address to, uint256 amount) returns (bool)'])
1471
+ .encodeFunctionData('transfer', [fee.recipient, BigInt(fee.amountRaw)]);
1472
+ const tx = await w.sendTransaction({ to: fee.token, data });
1473
+ // Recorded the moment it is broadcast: a retry resumes from here and never pays twice.
1474
+ updateSpend(idempotencyKey, { stage: 'sent', txHash: tx.hash });
1475
+ try {
1476
+ await tx.wait();
1477
+ }
1478
+ catch (err) {
1479
+ if (err.code === 'CALL_EXCEPTION') {
1480
+ updateSpend(idempotencyKey, { stage: 'created', txHash: undefined });
1481
+ return fail('FEE_REVERTED', `The fee transfer ${tx.hash} reverted, so nothing was paid. Check the wallet's USDC on ${fee.chain} and retry.`);
1482
+ }
1483
+ // Not confirmed yet: the backend waits for the receipt itself.
1484
+ }
1485
+ feeTxHash = tx.hash;
1486
+ }
1487
+ else {
1488
+ putSpend({ idempotencyKey, kind: 'deploy', stage: 'created', createdAt: now, updatedAt: now });
1489
+ }
1490
+ const agent = await deploy(feeTxHash);
1491
+ updateSpend(idempotencyKey, { stage: 'confirmed', agentId: agent.id });
1492
+ return ok({
1493
+ ...summary(agent),
1494
+ feeTxHash,
1495
+ hint: agent.started ? 'The agent is running.' : 'The agent was created but did not start — start it with start_agent.',
1496
+ });
1497
+ }
1498
+ catch (err) {
1499
+ return fail(err.code ?? 'DEPLOY_FAILED', `${err.message}.${paidNote()}`);
1500
+ }
1501
+ });
1213
1502
  return { settlement };
1214
1503
  }
1215
1504
  //# sourceMappingURL=rent.js.map