@blindmarket/mcp-server 0.3.6 → 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,10 +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
- * On Base nothing signs locally — the relay signs from the API key's owner
99
- * wallet — so a missing BLINDMARKET_PRIVATE_KEY is not an error there. */
102
+ * On a relay chain (Base) nothing signs locally — the relay signs from the
103
+ * API key's owner wallet — so a missing BLINDMARKET_PRIVATE_KEY is not an
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. */
100
106
  async function requireFunding() {
101
107
  let s;
102
108
  try {
@@ -105,26 +111,62 @@ export function registerRentTools(server, cfg, walletCtx) {
105
111
  catch (err) {
106
112
  return { error: fail(err.code ?? 'SETTLEMENT_UNKNOWN', err.message) };
107
113
  }
108
- if (s.mode === 'base')
114
+ if (isErc20Settlement(s))
109
115
  return { s, payFrom: s.payFrom };
110
116
  if (!walletCtx) {
111
117
  return { error: fail('NO_WALLET', 'Spending on 0G needs a local funding wallet — set BLINDMARKET_PRIVATE_KEY (see wallet_status)') };
112
118
  }
119
+ // The escrow address is compared before every send, but not the chain:
120
+ // a testnet backend's escrow address, paid on the mainnet RPC, is some
121
+ // other account there. A backend that names its 0G chain id settles it.
122
+ if (s.chainId !== undefined && walletCtx.chainId !== s.chainId) {
123
+ return {
124
+ error: fail('CHAIN_MISMATCH', `The backend settles 0G on chain ${s.chainId}, but BLINDMARKET_PRIVATE_KEY signs on chain ${walletCtx.chainId} (BLINDMARKET_RPC_URL / BLINDMARKET_CHAIN_ID). Native value sent there would not reach this backend's escrow. Point both at chain ${s.chainId}.`),
125
+ };
126
+ }
113
127
  return { s, payFrom: walletCtx.wallet.address };
114
128
  }
129
+ /** A new escrow is funded where POST /api/v1/tasks builds: the backend's
130
+ * posting chain. A process forced onto another chain (to finish or refund
131
+ * tasks already there) cannot post. Unknown on an older backend. */
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)
147
+ return null;
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.`);
149
+ e.code = 'NOT_POSTING_CHAIN';
150
+ return e;
151
+ }
115
152
  const ERC20 = new Interface([
116
153
  'function allowance(address owner, address spender) view returns (uint256)',
117
154
  'function approve(address spender, uint256 amount) returns (bool)',
118
155
  'function balanceOf(address owner) view returns (uint256)',
119
156
  ]);
120
- function usdc(s) {
121
- return new Contract(s.usdcAddress, ERC20, s.provider);
157
+ function settlementToken(s) {
158
+ return new Contract(s.token.address, ERC20, s.provider);
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);
122
164
  }
123
165
  /** Spendable balance of whoever pays, in the settlement token's units. */
124
166
  async function payFromBalance(s, payFrom) {
125
167
  try {
126
- const raw = s.mode === 'base'
127
- ? await usdc(s).balanceOf(payFrom)
168
+ const raw = isErc20Settlement(s)
169
+ ? await settlementToken(s).balanceOf(payFrom)
128
170
  : await walletCtx.provider.getBalance(payFrom);
129
171
  return formatUnits(raw, s.decimals);
130
172
  }
@@ -136,19 +178,22 @@ export function registerRentTools(server, cfg, walletCtx) {
136
178
  * the task, and the tx carries no chainId. Discovery is only a hint about
137
179
  * that chain (see settlement.ts). So before broadcasting, check the tx
138
180
  * targets the escrow THIS mode expects — otherwise native value goes to a
139
- * Base address on the 0G RPC, or a 0G refund is relayed onto Base. A
140
- * mismatch also drops the cached mode so the next call re-asks. */
181
+ * relay chain's address on the 0G RPC, or a 0G refund is relayed onto
182
+ * another chain. A mismatch also drops the cached mode so the next call
183
+ * re-asks. */
141
184
  async function verifyTarget(s, to, what) {
142
185
  const target = String(to).toLowerCase();
143
- const expected = s.mode === 'base' ? s.escrowAddress : s.escrowAddress;
186
+ const expected = s.escrowAddress;
144
187
  if (expected) {
145
188
  if (target === expected.toLowerCase())
146
189
  return;
147
190
  settlement.invalidate();
148
191
  const e = new Error(`backend built ${what} for ${to} but this process is in ${s.mode} mode expecting escrow ${expected}. ` +
149
- (s.mode === 'base'
150
- ? 'This task is escrowed on 0G — handle it with BLINDMARKET_SETTLEMENT=0g and a local key, or from the web app.'
151
- : 'The backend is building Base transactions — re-run and discovery will re-check, or set BLINDMARKET_SETTLEMENT=base.'));
192
+ (s.escrowChains
193
+ ? `This task is escrowed on another chain — set BLINDMARKET_SETTLEMENT to the chain that holds it (this backend has escrows on ${s.escrowChains.join(', ')}; 0G needs a local key), or use the web app.`
194
+ : s.payment === 'relay-erc20'
195
+ ? 'This task is escrowed on 0G — handle it with BLINDMARKET_SETTLEMENT=0g and a local key, or from the web app.'
196
+ : 'The backend is building Base transactions — re-run and discovery will re-check, or set BLINDMARKET_SETTLEMENT=base.'));
152
197
  e.code = 'ESCROW_MISMATCH';
153
198
  throw e;
154
199
  }
@@ -191,6 +236,20 @@ export function registerRentTools(server, cfg, walletCtx) {
191
236
  // default it applies to this body: user-pays.
192
237
  return { hash: r.hash, isUserOp: r.isUserOp === true, gas: r.gas ?? 'user-pays' };
193
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
+ }
194
253
  /** Wait for a relayed tx to land. A plain hash can be polled for its receipt;
195
254
  * a user-op hash cannot (getTransactionReceipt is always null for it), so
196
255
  * that case returns at once and the caller confirms by on-chain STATE —
@@ -202,7 +261,7 @@ export function registerRentTools(server, cfg, walletCtx) {
202
261
  const receipt = await s.provider.getTransactionReceipt(hash).catch(() => null);
203
262
  if (receipt) {
204
263
  if (receipt.status === 0) {
205
- const e = new Error(`relayed tx ${hash} reverted`);
264
+ const e = new Error(`tx ${hash} reverted`);
206
265
  e.code = 'TX_REVERTED';
207
266
  throw e;
208
267
  }
@@ -210,21 +269,26 @@ export function registerRentTools(server, cfg, walletCtx) {
210
269
  }
211
270
  await new Promise((r) => setTimeout(r, 3000));
212
271
  }
213
- 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`);
214
273
  e.code = 'TX_PENDING';
215
274
  throw e;
216
275
  }
217
- /** Base only: createTask pulls USDC via transferFrom, so the escrow needs an
218
- * allowance first. Confirmed by re-reading allowance() rather than by
219
- * receipt, which is what makes the user-op case decidable. */
276
+ /** ERC-20 chains: createTask pulls the token via transferFrom, so the
277
+ * escrow needs an allowance first. Confirmed by re-reading allowance()
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. */
220
281
  async function ensureAllowance(s, record) {
221
282
  const need = BigInt(record.amountWei);
222
- const token = usdc(s);
283
+ const token = settlementToken(s);
223
284
  if ((await token.allowance(s.payFrom, s.escrowAddress)) >= need)
224
- return;
285
+ return undefined;
286
+ let nextNonce;
225
287
  const approve = async () => {
226
288
  const data = ERC20.encodeFunctionData('approve', [s.escrowAddress, need]);
227
- const { hash } = await relaySend(s, { to: s.usdcAddress, data });
289
+ const { hash, nonce } = await sendErc20(s, { to: s.token.address, data });
290
+ if (nonce !== undefined)
291
+ nextNonce = nonce + 1;
228
292
  // Persist BEFORE waiting: a crash here must resume into the poll below.
229
293
  updateSpend(record.idempotencyKey, { stage: 'approved', approveTxHash: hash });
230
294
  record.stage = 'approved';
@@ -241,47 +305,53 @@ export function registerRentTools(server, cfg, walletCtx) {
241
305
  if (record.stage === 'created') {
242
306
  await approve();
243
307
  if (await settled())
244
- return;
308
+ return nextNonce;
245
309
  }
246
310
  // Resumed at 'approved' (or the fresh approve never landed): the earlier
247
311
  // approve was dropped or reverted. Sending another is safe — ERC-20
248
312
  // approve SETS the allowance, it does not add — and it is the only way
249
313
  // out of this stage, so do it rather than leave the record stuck.
250
314
  if (await settled())
251
- return;
315
+ return nextNonce;
252
316
  await approve();
253
317
  if (await settled())
254
- return;
255
- const e = new Error(`USDC allowance still below ${formatUnits(need, s.decimals)} after two approves (last ${record.approveTxHash}) — check the relay wallet's USDC balance and retry with the same idempotencyKey`);
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`);
256
320
  e.code = 'APPROVE_PENDING';
257
321
  throw e;
258
322
  }
259
323
  /** Fund escrow + index — the shared tail of rent_service and post_task.
260
324
  * Resumable at every stage via the spend ledger.
261
325
  *
262
- * Two funding paths, chosen by settlement mode:
263
- * 0g — native value from the local wallet, signed and sent here.
264
- * base — USDC via transferFrom: approve first (ensureAllowance), then the
265
- * createTask the backend built, both through the Privy relay with
266
- * no local signing at all. The backend picks the escrow; we only
267
- * check it is the one we approved. */
326
+ * Two funding paths, chosen by `payment`:
327
+ * local-native — native 0G from the local wallet, signed and sent here.
328
+ * relay-erc20 — the ERC-20 (USDC on Base) via transferFrom: approve
329
+ * first (ensureAllowance), then the createTask the backend built,
330
+ * both through the Privy relay with no local signing at all. The
331
+ * backend picks the escrow; we only check it is the one we approved. */
268
332
  async function fundAndIndex(record) {
269
- const s = await settlement();
270
333
  let { txHash } = record;
271
- // A record remembers the chain it started on. If the backend flips mode
272
- // between attempts, re-funding through the other path would double-fund
273
- // or send native value into a USDC transferFrom — refuse instead.
274
- if (record.settlement && record.settlement !== s.mode) {
275
- 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`);
276
- e.code = 'SETTLEMENT_CHANGED';
277
- throw e;
278
- }
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.
279
337
  if (record.stage === 'created' || record.stage === 'approved') {
280
- if (s.mode === 'base')
281
- await ensureAllowance(s, record);
282
- const { unsignedTx } = await api('POST', '/api/v1/tasks', {
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);
349
+ if (refused)
350
+ throw refused;
351
+ const nonce = isErc20Settlement(s) ? await ensureAllowance(s, record) : undefined;
352
+ const { unsignedTx, chain: builtChain, chainId: builtChainId } = await api('POST', '/api/v1/tasks', {
283
353
  taskHash: record.taskHash,
284
- token: s.mode === 'base' ? s.usdcAddress : ZERO_TOKEN,
354
+ token: isErc20Settlement(s) ? s.token.address : ZERO_TOKEN,
285
355
  amount: record.amountWei,
286
356
  locationZone: 'global',
287
357
  duration: String(record.durationSecs ?? 3600),
@@ -292,11 +362,20 @@ export function registerRentTools(server, cfg, walletCtx) {
292
362
  rootHash: record.rootHash,
293
363
  wrappedKeys: record.privacy === 'public' ? undefined : record.wrappedKeys,
294
364
  });
295
- // Either branch: the tx must target the escrow this mode expects. On
296
- // Base that is also the escrow the allowance above was granted to.
365
+ // Either branch: the tx must target the escrow this mode expects. On a
366
+ // relay chain that is also the escrow the allowance above was granted to.
367
+ // A chain-aware backend also names the chain it built for (older ones
368
+ // return only the tx, and are checked by address alone, as before).
369
+ if (s.postingChain !== undefined && ((builtChain !== undefined && builtChain !== s.mode) ||
370
+ (builtChainId !== undefined && s.chainId !== undefined && Number(builtChainId) !== s.chainId))) {
371
+ settlement.invalidate();
372
+ const e = new Error(`backend built createTask for ${builtChain ?? '?'} (chain ${builtChainId ?? '?'}) but this process settles on ${s.mode} (chain ${s.chainId ?? '?'}) — re-run and discovery will re-check`);
373
+ e.code = 'ESCROW_MISMATCH';
374
+ throw e;
375
+ }
297
376
  await verifyTarget(s, unsignedTx.to, 'createTask');
298
- if (s.mode === 'base') {
299
- 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);
300
379
  // Persist BEFORE waiting, same reasoning as the 0G branch below.
301
380
  updateSpend(record.idempotencyKey, { stage: 'funded', txHash: hash, isUserOp, gas });
302
381
  record.gas = gas;
@@ -310,6 +389,8 @@ export function registerRentTools(server, cfg, walletCtx) {
310
389
  data: unsignedTx.data,
311
390
  value: BigInt(record.amountWei),
312
391
  gasLimit: GAS_LIMIT,
392
+ // ethers refuses to send when its provider is on another chain.
393
+ ...(s.chainId !== undefined ? { chainId: s.chainId } : {}),
313
394
  });
314
395
  // Persist the tx hash BEFORE waiting: if we crash mid-confirmation the
315
396
  // resume path re-runs /tasks/index with this hash instead of re-funding.
@@ -361,7 +442,7 @@ export function registerRentTools(server, cfg, walletCtx) {
361
442
  // ── rent_service ──────────────────────────────────────────────────────────
362
443
  server.registerTool('rent_service', {
363
444
  title: 'Rent an Agent Service',
364
- 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).',
365
446
  inputSchema: {
366
447
  serviceId: z.number().int().positive().describe('Service id from browse_services / get_service'),
367
448
  prompt: z.string().min(1).max(100_000).describe('What you want the agent to do'),
@@ -373,11 +454,8 @@ export function registerRentTools(server, cfg, walletCtx) {
373
454
  },
374
455
  annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
375
456
  }, async ({ serviceId, prompt, idempotencyKey, privacy, confirm, quoteId, waitSeconds }) => {
376
- const f = await requireFunding();
377
- if ('error' in f)
378
- return f.error;
379
- const { s, payFrom } = f;
380
- // 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.
381
459
  const existing = getSpend(idempotencyKey);
382
460
  if (existing) {
383
461
  if (existing.stage === 'indexed') {
@@ -393,6 +471,13 @@ export function registerRentTools(server, cfg, walletCtx) {
393
471
  return fail(err.code ?? 'RESUME_FAILED', err.message);
394
472
  }
395
473
  }
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);
479
+ if (offPosting)
480
+ return fail(offPosting.code, offPosting.message);
396
481
  const service = await api('GET', `/api/v1/marketplace/services/${serviceId}`);
397
482
  const isPublic = privacy === 'public';
398
483
  if (!isPublic && !service.agent_public_key) {
@@ -405,10 +490,10 @@ export function registerRentTools(server, cfg, walletCtx) {
405
490
  // A service priced in 18-decimal units on a 6-decimal chain would quote
406
491
  // as a trillion USDC and relay an approve for it before createTask
407
492
  // failed. The backend converts such prices since migration 31, but an
408
- // older backend may still serve one. 1,000,000 USDC per call is far
493
+ // older backend may still serve one. 1,000,000 tokens per call is far
409
494
  // above any real listing — refuse.
410
- if (s.mode === 'base' && priceRaw > 1000000n * 10n ** 6n) {
411
- return fail('PRICE_UNITS_SUSPECT', `service ${serviceId} lists price_raw=${priceRaw} which is ${formatUnits(priceRaw, 6)} USDC — this looks like an 18-decimal 0G price on a USDC chain. Not sending. Re-list the service in USDC base units.`);
495
+ if (s.decimals < 18 && priceRaw > 1000000n * 10n ** BigInt(s.decimals)) {
496
+ 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.`);
412
497
  }
413
498
  const price = formatUnits(priceRaw, s.decimals);
414
499
  if (!confirm) {
@@ -467,7 +552,7 @@ export function registerRentTools(server, cfg, walletCtx) {
467
552
  requiredCapabilities: [],
468
553
  amountWei: String(service.price_raw),
469
554
  settlement: s.mode,
470
- token: s.mode === 'base' ? s.usdcAddress : ZERO_TOKEN,
555
+ token: isErc20Settlement(s) ? s.token.address : ZERO_TOKEN,
471
556
  durationSecs: 3600,
472
557
  createdAt: new Date().toISOString(),
473
558
  updatedAt: new Date().toISOString(),
@@ -480,7 +565,7 @@ export function registerRentTools(server, cfg, walletCtx) {
480
565
  catch (err) {
481
566
  const code = err.code;
482
567
  if (code === 'NOT_TASK_AGENT') {
483
- 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.');
484
569
  }
485
570
  return fail(code ?? 'RENT_FAILED', err.message);
486
571
  }
@@ -488,7 +573,7 @@ export function registerRentTools(server, cfg, walletCtx) {
488
573
  // ── post_task ─────────────────────────────────────────────────────────────
489
574
  server.registerTool('post_task', {
490
575
  title: 'Post a Task to the Open Market',
491
- 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.',
492
577
  inputSchema: {
493
578
  instructions: z.string().min(1).max(100_000).describe('The task brief'),
494
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'),
@@ -502,10 +587,7 @@ export function registerRentTools(server, cfg, walletCtx) {
502
587
  },
503
588
  annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
504
589
  }, async ({ instructions, amount, amount0G, idempotencyKey, capabilities, durationSeconds, privacy, confirm, quoteId }) => {
505
- const f = await requireFunding();
506
- if ('error' in f)
507
- return f.error;
508
- const { s, payFrom } = f;
590
+ // Checked before settlement: a funded spend finishes without it.
509
591
  const existing = getSpend(idempotencyKey);
510
592
  if (existing) {
511
593
  if (existing.stage === 'indexed') {
@@ -518,6 +600,13 @@ export function registerRentTools(server, cfg, walletCtx) {
518
600
  return fail(err.code ?? 'RESUME_FAILED', err.message);
519
601
  }
520
602
  }
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);
608
+ if (offPosting)
609
+ return fail(offPosting.code, offPosting.message);
521
610
  const isPublic = privacy === 'public';
522
611
  const amountStr = amount ?? amount0G;
523
612
  if (!amountStr) {
@@ -595,7 +684,7 @@ export function registerRentTools(server, cfg, walletCtx) {
595
684
  requiredCapabilities: capabilities ?? [],
596
685
  amountWei: amountWei.toString(),
597
686
  settlement: s.mode,
598
- token: s.mode === 'base' ? s.usdcAddress : ZERO_TOKEN,
687
+ token: isErc20Settlement(s) ? s.token.address : ZERO_TOKEN,
599
688
  durationSecs: durationSeconds ?? 86400,
600
689
  createdAt: new Date().toISOString(),
601
690
  updatedAt: new Date().toISOString(),
@@ -607,7 +696,7 @@ export function registerRentTools(server, cfg, walletCtx) {
607
696
  catch (err) {
608
697
  const code = err.code;
609
698
  if (code === 'NOT_TASK_AGENT') {
610
- 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.');
611
700
  }
612
701
  return fail(code ?? 'POST_FAILED', err.message);
613
702
  }
@@ -643,7 +732,8 @@ export function registerRentTools(server, cfg, walletCtx) {
643
732
  //
644
733
  // Chain note: the backend resolves which chain holds the task
645
734
  // (resolveTaskChainById) and builds the unsigned tx for it; this side only
646
- // decides who signs — the local wallet on 0G, the Privy relay on Base — and
735
+ // decides who signs — the local wallet on 0G, the Privy relay on a relay
736
+ // chain (Base) — and
647
737
  // verifies the tx targets the escrow that mode expects before sending
648
738
  // (verifyTarget). Task state is read from the chain the mode names, not
649
739
  // from GET /tasks/:id, which is bound to the 0G escrow.
@@ -653,32 +743,55 @@ export function registerRentTools(server, cfg, walletCtx) {
653
743
  }
654
744
  /** Resolve a task id-or-hash to its live on-chain state.
655
745
  *
656
- * GET /api/v1/tasks/:id accepts either form — but it reads the 0G escrow
657
- * only (escrowService.getTask is bound to the 0G contract), so on Base its
658
- * struct is for whatever 0G task happens to share the id. In Base mode we
659
- * use it just to turn a hash into an id, then read the struct from the
660
- * Base escrow ourselves over the read-only provider. */
746
+ * GET /api/v1/tasks/:id accepts either form — but older backends read the
747
+ * 0G escrow only (escrowService.getTask was bound to the 0G contract), so
748
+ * on a relay chain its struct is for whatever 0G task happens to share the
749
+ * id. On a relay chain we use it just to turn a hash into an id, then read
750
+ * the struct from that chain's escrow ourselves over the read-only
751
+ * provider. */
661
752
  async function loadTask(s, task) {
662
- if (s.mode !== 'base') {
663
- return api('GET', `/api/v1/tasks/${encodeURIComponent(task)}`);
753
+ /** The backend said the task is on another chain: its id means a different task here. */
754
+ const onOtherChain = (taskId, chain) => {
755
+ const label = s.chain === 'base' ? 'Base' : s.chain;
756
+ const e = new Error(`task ${taskId} is a ${chain} task, and this process settles on ${label} — handle it with BLINDMARKET_SETTLEMENT=${chain}`);
757
+ // The code names the chain; on Base it is the TASK_NOT_ON_BASE it always was.
758
+ e.code = `TASK_NOT_ON_${s.chain.toUpperCase().replace(/-/g, '_')}`;
759
+ return e;
760
+ };
761
+ if (!isErc20Settlement(s)) {
762
+ const detail = await api('GET', `/api/v1/tasks/${encodeURIComponent(task)}`);
763
+ if (detail.chain && detail.chain !== s.chain)
764
+ throw onOtherChain(detail.taskId, detail.chain);
765
+ return detail;
664
766
  }
665
- // Base: the escrow is the authority, and we can read it directly. Only ask
767
+ // Relay chain: the escrow is the authority, and we can read it directly. Only ask
666
768
  // the backend when we need a hash resolved to an id — that endpoint makes
667
769
  // several Redis round trips and is the slowest thing in this path, so a
668
770
  // numeric id must not pay for it.
669
771
  let taskId;
772
+ let backendChain;
670
773
  if (/^\d+$/.test(task)) {
671
774
  taskId = task;
672
775
  }
673
776
  else {
674
777
  const viaBackend = await api('GET', `/api/v1/tasks/${encodeURIComponent(task)}`);
675
778
  taskId = viaBackend.taskId;
779
+ backendChain = viaBackend.chain;
780
+ // The same id can exist on this chain's escrow too (another task of the
781
+ // same poster): reading it would act on the wrong task.
782
+ if (backendChain && backendChain !== s.chain)
783
+ throw onOtherChain(taskId, backendChain);
676
784
  }
677
785
  const escrow = new Contract(s.escrowAddress, ESCROW_READ_ABI, s.provider);
678
786
  const t = await escrow.getTask(BigInt(taskId));
679
787
  if (String(t.agent).toLowerCase() === ZERO_TOKEN) {
680
- const e = new Error(`task ${taskId} does not exist on the Base escrow ${s.escrowAddress} — it is probably a 0G task; handle it with BLINDMARKET_SETTLEMENT=0g`);
681
- e.code = 'TASK_NOT_ON_BASE';
788
+ const label = s.chain === 'base' ? 'Base' : s.chain;
789
+ const hint = s.escrowChains
790
+ ? `it is on another chain; set BLINDMARKET_SETTLEMENT to the one that holds it (this backend has escrows on ${s.escrowChains.join(', ')})`
791
+ : 'it is probably a 0G task; handle it with BLINDMARKET_SETTLEMENT=0g';
792
+ const e = new Error(`task ${taskId} does not exist on the ${label} escrow ${s.escrowAddress} — ${hint}`);
793
+ // The code names the chain; on Base it is the TASK_NOT_ON_BASE it always was.
794
+ e.code = `TASK_NOT_ON_${s.chain.toUpperCase().replace(/-/g, '_')}`;
682
795
  throw e;
683
796
  }
684
797
  return {
@@ -692,8 +805,8 @@ export function registerRentTools(server, cfg, walletCtx) {
692
805
  decimals: s.decimals,
693
806
  };
694
807
  }
695
- /** Amount formatted against the task's OWN decimals — a Base task settles in
696
- * USDC (6), not the wallet's native 18. */
808
+ /** Amount formatted against the task's OWN decimals — a relay-chain task
809
+ * settles in its ERC-20 (USDC: 6), not the wallet's native 18. */
697
810
  function refundAmount(detail) {
698
811
  return formatUnits(BigInt(detail.amount), detail.decimals ?? 18);
699
812
  }
@@ -714,7 +827,7 @@ export function registerRentTools(server, cfg, walletCtx) {
714
827
  }
715
828
  /** Broadcast the refund and wait for it — the shared tail of both tools.
716
829
  * Resumable: a record past 'created' waits on the tx it already saved.
717
- * Same two paths as fundAndIndex: local wallet on 0G, Privy relay on Base.
830
+ * Same two paths as fundAndIndex: local wallet on 0G, Privy relay on a relay chain.
718
831
  * The backend resolves which chain holds the task and builds the tx for
719
832
  * it; this only decides who signs. */
720
833
  async function sendRefund(record) {
@@ -722,20 +835,22 @@ export function registerRentTools(server, cfg, walletCtx) {
722
835
  const taskId = record.taskId;
723
836
  let { txHash } = record;
724
837
  if (record.settlement && record.settlement !== s.mode) {
725
- 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`);
726
839
  e.code = 'SETTLEMENT_CHANGED';
727
840
  throw e;
728
841
  }
729
842
  if (record.stage === 'created') {
730
843
  const route = record.kind === 'cancel' ? 'cancel' : 'timeout';
731
- 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);
732
847
  // The backend resolves the chain that holds the task and builds for it;
733
848
  // the tx carries no chainId. If that chain is not the one this mode
734
- // broadcasts on, stop here — relaying a 0G refund onto Base lands on an
735
- // address with no escrow and burns the gas.
849
+ // broadcasts on, stop here — relaying a 0G refund onto another chain
850
+ // lands on an address with no escrow and burns the gas.
736
851
  await verifyTarget(s, unsignedTx.to, `${route}Task`);
737
- if (s.mode === 'base') {
738
- 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 });
739
854
  // Persist BEFORE waiting, same reasoning as the 0G branch below.
740
855
  updateSpend(record.idempotencyKey, { stage: 'sent', txHash: hash, isUserOp, gas });
741
856
  record.gas = gas;
@@ -748,6 +863,7 @@ export function registerRentTools(server, cfg, walletCtx) {
748
863
  to: unsignedTx.to,
749
864
  data: unsignedTx.data,
750
865
  gasLimit: GAS_LIMIT,
866
+ ...(s.chainId !== undefined ? { chainId: s.chainId } : {}),
751
867
  });
752
868
  // Persist the hash BEFORE waiting, same reasoning as fundAndIndex: a
753
869
  // crash mid-confirmation must resume onto THIS tx, not broadcast another.
@@ -757,7 +873,7 @@ export function registerRentTools(server, cfg, walletCtx) {
757
873
  }
758
874
  }
759
875
  else if (txHash) {
760
- if (s.mode === 'base')
876
+ if (isErc20Settlement(s))
761
877
  await waitRelayed(s, txHash, record.isUserOp ?? false);
762
878
  else
763
879
  await walletCtx.provider.waitForTransaction(txHash);
@@ -767,7 +883,28 @@ export function registerRentTools(server, cfg, walletCtx) {
767
883
  }
768
884
  await waitCancelled(s, taskId);
769
885
  updateSpend(record.idempotencyKey, { stage: 'confirmed' });
770
- 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;
771
908
  }
772
909
  /** Shared resume arm: an idempotencyKey that has already moved. The kind
773
910
  * guard matters — the ledger is one namespace shared with rent/post, and
@@ -1029,7 +1166,7 @@ export function registerRentTools(server, cfg, walletCtx) {
1029
1166
  return f.error;
1030
1167
  const { s, payFrom } = f;
1031
1168
  // requireFunding returns an error on 0G without a wallet, so walletCtx is
1032
- // non-null whenever s.mode is '0g' from here on.
1169
+ // non-null whenever s.payment is 'local-native' from here on.
1033
1170
  let detail;
1034
1171
  try {
1035
1172
  detail = await loadTask(s, task);
@@ -1085,12 +1222,22 @@ export function registerRentTools(server, cfg, walletCtx) {
1085
1222
  }
1086
1223
  const tx = sub.unsignedSubmitEvidence;
1087
1224
  await verifyTarget(s, tx.to, 'submitEvidence');
1088
- if (s.mode === 'base') {
1225
+ if (s.payment === 'relay-erc20') {
1089
1226
  const sent = await relaySend(s, { to: tx.to, data: tx.data });
1090
1227
  submitTxHash = sent.hash;
1091
1228
  gas = sent.gas;
1092
1229
  await waitRelayed(s, sent.hash, sent.isUserOp);
1093
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
+ }
1094
1241
  else {
1095
1242
  // submitEvidence is onlyWorker: the backend built the tx for the
1096
1243
  // API key's wallet (tx.from). If BLINDMARKET_PRIVATE_KEY is a
@@ -1130,7 +1277,7 @@ export function registerRentTools(server, cfg, walletCtx) {
1130
1277
  paidTo: done ? payFrom : undefined,
1131
1278
  submissionAttempts: after.submissionAttempts,
1132
1279
  hint: done
1133
- ? `Escrow released: ${formatUnits(BigInt(after.amount), after.decimals ?? 6)} ${s.mode === 'base' ? 'USDC' : '0G'} minus the marketplace fee is now in ${payFrom}.`
1280
+ ? `Escrow released: ${formatUnits(BigInt(after.amount), after.decimals ?? s.decimals)} ${s.symbol} minus the marketplace fee is now in ${payFrom}.`
1134
1281
  : fin.awaitingPosterApproval
1135
1282
  ? 'Manual-verification task: the poster must approve via verify_task before the escrow releases.'
1136
1283
  : `Verification did not pass (${(fin.verificationResult?.reasons ?? []).join('; ') || 'no reasons given'}). ` +
@@ -1143,6 +1290,215 @@ export function registerRentTools(server, cfg, walletCtx) {
1143
1290
  return fail(err.code ?? 'COMPLETE_FAILED', err.message);
1144
1291
  }
1145
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
+ });
1146
1502
  return { settlement };
1147
1503
  }
1148
1504
  //# sourceMappingURL=rent.js.map