@blindmarket/sdk 0.6.4 → 0.7.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/index.js CHANGED
@@ -1,18 +1,14 @@
1
1
  import { ethers } from 'ethers';
2
- // ── Helpers ─────────────────────────────────────────────────────────────────
3
- class ApiError extends Error {
4
- status;
5
- body;
6
- code;
7
- constructor(status, message, body,
8
- /** Backend error code (e.g. 'NEEDS_WRAP', 'NOT_SUBMITTED_ON_CHAIN'), when the envelope carried one. */
9
- code) {
10
- super(message);
11
- this.status = status;
12
- this.body = body;
13
- this.code = code;
14
- this.name = 'ApiError';
15
- }
2
+ import { ApiError } from './apiError.js';
3
+ import { sendAndWait, assertSignerChain, ensureAllowance, tokenBalance, UnconfirmedTransactionError, DEFAULT_CONFIRM_TIMEOUT_MS, } from './onchain.js';
4
+ import { generateAesKey, aesEncrypt, eciesEncrypt, sha256, bytesToHex } from './crypto/index.js';
5
+ /** A whole number of base units from a string or bigint; throws 400 INVALID_AMOUNT otherwise. */
6
+ function wholeNumber(value, name) {
7
+ if (typeof value === 'bigint')
8
+ return value;
9
+ if (typeof value === 'string' && /^\d+$/.test(value))
10
+ return BigInt(value);
11
+ throw new ApiError(400, `${name} must be a whole number of the token's smallest unit (USDC has 6 decimals: '2500000' is 2.5 USDC), not ${JSON.stringify(value)}. Nothing was sent.`, undefined, 'INVALID_AMOUNT');
16
12
  }
17
13
  // ── Main client ─────────────────────────────────────────────────────────────
18
14
  /**
@@ -86,7 +82,10 @@ export class BlindMarket {
86
82
  });
87
83
  const json = await res.json();
88
84
  if (!json.success) {
89
- throw new ApiError(res.status, json.error?.message ?? `HTTP ${res.status}`, json, json.error?.code);
85
+ const err = new ApiError(res.status, json.error?.message ?? `HTTP ${res.status}`, json, json.error?.code);
86
+ if (typeof json.error?.reason === 'string')
87
+ err.reason = json.error.reason;
88
+ throw err;
90
89
  }
91
90
  return json.data;
92
91
  }
@@ -125,16 +124,20 @@ export class BlindMarket {
125
124
  return this.req('POST', `/api/v1/tasks/${taskId}/assign`, { worker });
126
125
  }
127
126
  /**
128
- * Build an unsigned `cancelTask` transaction.
127
+ * Build an unsigned `cancelTask` transaction (the refund of a task no one
128
+ * has taken). `chain`/`chainId` name where to send it. cancelAndRefund()
129
+ * builds, signs and sends it for you.
129
130
  */
130
- async cancelTask(taskId) {
131
- return this.req('POST', `/api/v1/tasks/${taskId}/cancel`);
131
+ async cancelTask(taskId, chain) {
132
+ // Task ids collide across chains: naming the chain builds for that one.
133
+ return this.req('POST', `/api/v1/tasks/${taskId}/cancel`, chain ? { chain } : undefined);
132
134
  }
133
135
  /**
134
- * Build an unsigned `claimTimeout` transaction.
136
+ * Build an unsigned `claimTimeout` transaction (the refund of a task whose
137
+ * deadline passed). reclaimAfterTimeout() builds, signs and sends it for you.
135
138
  */
136
- async claimTimeout(taskId) {
137
- return this.req('POST', `/api/v1/tasks/${taskId}/timeout`);
139
+ async claimTimeout(taskId, chain) {
140
+ return this.req('POST', `/api/v1/tasks/${taskId}/timeout`, chain ? { chain } : undefined);
138
141
  }
139
142
  /**
140
143
  * Build an unsigned `submitEvidence` transaction.
@@ -142,10 +145,308 @@ export class BlindMarket {
142
145
  async submitEvidence(params) {
143
146
  return this.req('POST', '/api/v1/submissions/submit', params);
144
147
  }
148
+ // ── Posting a task end to end ─────────────────────────────────────────────
149
+ /**
150
+ * Where new tasks are posted and what each chain settles in
151
+ * (`GET /health/settlement`): no auth, no RPC reads on the backend.
152
+ */
153
+ async getSettlement() {
154
+ return this.req('GET', '/health/settlement');
155
+ }
156
+ /**
157
+ * Post a task end to end, from the API key's own wallet: encrypt the brief
158
+ * (unless public) and wrap its key to the posting chain's executors, upload
159
+ * it, build createTask, approve the escrow for the amount when the token is
160
+ * an ERC-20, fund the escrow, and list the task (`POST /a2a/tasks/index`).
161
+ *
162
+ * The wallet signs locally, on the backend's posting chain (Arc on
163
+ * production, where gas is paid in USDC). Before anything is sent it checks
164
+ * the signer is the API key's owner, that its RPC is on the posting chain,
165
+ * that the wallet holds the amount, and that the backend built the tx for
166
+ * the escrow it advertises. The funding hash goes to `onFunded` as soon as
167
+ * it is sent; an error after that carries it as `err.txHash`, and
168
+ * indexTask() lists the funded task without paying again.
169
+ *
170
+ * @example
171
+ * const task = await bb.postTask(
172
+ * { instructions: 'Summarise this paper in 5 bullets: …', amountRaw: '2000000' }, // 2 USDC
173
+ * { onFunded: ({ txHash }) => saveSomewhere(txHash) },
174
+ * );
175
+ */
176
+ async postTask(params, opts = {}) {
177
+ const amount = wholeNumber(params.amountRaw, 'amountRaw');
178
+ if (amount <= 0n)
179
+ throw new ApiError(400, 'amountRaw must be above 0. Nothing was sent.', undefined, 'INVALID_AMOUNT');
180
+ if (opts.maxAmountRaw !== undefined && amount > BigInt(opts.maxAmountRaw)) {
181
+ throw new ApiError(402, `The escrow of ${amount} is above your limit of ${opts.maxAmountRaw}. Nothing was sent.`, undefined, 'AMOUNT_ABOVE_MAX');
182
+ }
183
+ const duration = params.durationSeconds ?? 86_400;
184
+ if (!Number.isInteger(duration) || duration < 3_600 || duration > 90 * 86_400) {
185
+ throw new ApiError(400, 'durationSeconds must be a whole number from 3600 (1 hour) to 7776000 (90 days): the escrow refuses anything else. Nothing was sent.', undefined, 'INVALID_DURATION');
186
+ }
187
+ const privacy = params.privacy ?? 'private';
188
+ const verificationMode = params.verificationMode ?? 'auto';
189
+ const verificationCriteria = params.verificationCriteria
190
+ ?? (verificationMode === 'auto' ? { min_length: 10, pass_threshold: 60 } : undefined);
191
+ const requiredCapabilities = params.requiredCapabilities ?? [];
192
+ // Where the escrow is funded, and in what.
193
+ const { postingChain, chains } = await this.getSettlement();
194
+ const entry = chains.find((c) => c.chain === postingChain);
195
+ if (!postingChain || !entry || !entry.escrowAddress || !entry.token.address) {
196
+ throw new ApiError(503, `The backend has no chain to post new tasks on right now (posting chain: ${postingChain ?? 'none'}). Nothing was sent.`, { postingChain, chains }, 'SETTLEMENT_NOT_POSTABLE');
197
+ }
198
+ const escrow = entry.escrowAddress;
199
+ const token = entry.token.address;
200
+ const isNative = entry.token.kind === 'native';
201
+ const signer = opts.signer ?? this.signerOn(postingChain, 'Funding the escrow');
202
+ const poster = await signer.getAddress();
203
+ await this.assertSpender(poster, 'A task', true);
204
+ await assertSignerChain(signer, entry.chainId, `Funding the escrow on ${postingChain}`);
205
+ if (!isNative) {
206
+ const balance = await tokenBalance(signer, token, poster);
207
+ if (balance < amount) {
208
+ const fmt = (v) => ethers.formatUnits(v, entry.token.decimals);
209
+ throw new ApiError(402, `${poster} holds ${fmt(balance)} ${entry.token.symbol} on ${postingChain}; the escrow needs ${fmt(amount)}. Nothing was sent.`, undefined, 'INSUFFICIENT_BALANCE');
210
+ }
211
+ }
212
+ // The brief: plaintext, or encrypted to the executors that can take it.
213
+ const plaintext = new TextEncoder().encode(params.instructions);
214
+ let blob;
215
+ let wrappedKeys;
216
+ let aesKey;
217
+ if (privacy === 'public') {
218
+ blob = plaintext;
219
+ }
220
+ else {
221
+ const qs = new URLSearchParams({ capabilities: requiredCapabilities.join(','), chain: postingChain });
222
+ const { executors } = await this.req('GET', `/api/v1/a2a/executors?${qs}`);
223
+ let targets = executors.filter((e) => typeof e.publicKey === 'string' && e.publicKey.length > 0);
224
+ if (params.targetExecutor) {
225
+ const want = params.targetExecutor.toLowerCase();
226
+ targets = targets.filter((e) => e.address.toLowerCase() === want);
227
+ if (targets.length === 0) {
228
+ throw new ApiError(404, `${params.targetExecutor} is not a registered executor on ${postingChain} with a public key, so it could not read the brief. Nothing was sent.`, undefined, 'EXECUTOR_NOT_FOUND');
229
+ }
230
+ }
231
+ if (targets.length > 200) {
232
+ throw new ApiError(409, `${targets.length} executors match, more than the 200 a brief can be wrapped to. Narrow requiredCapabilities, name a targetExecutor, or post with privacy 'public'. Nothing was sent.`, undefined, 'TOO_MANY_EXECUTORS');
233
+ }
234
+ const key = await generateAesKey();
235
+ blob = await aesEncrypt(plaintext, key);
236
+ wrappedKeys = {};
237
+ for (const e of targets) {
238
+ try {
239
+ wrappedKeys[e.address.toLowerCase()] = bytesToHex(await eciesEncrypt(key, e.publicKey));
240
+ }
241
+ catch { /* a malformed public key: that executor can't be wrapped to */ }
242
+ }
243
+ if (Object.keys(wrappedKeys).length === 0) {
244
+ throw new ApiError(409, `No executor on ${postingChain} can decrypt an encrypted brief right now, so no one could take the task. Post with privacy 'public', or wait for executors to register. Nothing was sent.`, undefined, 'NO_EXECUTORS');
245
+ }
246
+ aesKey = bytesToHex(key);
247
+ }
248
+ const taskHash = `0x${bytesToHex(await sha256(blob))}`;
249
+ const { rootHash } = await this.uploadBlob(ethers.encodeBase64(blob));
250
+ const built = await this.createTask({
251
+ taskHash: taskHash,
252
+ token: token,
253
+ amount: amount.toString(),
254
+ locationZone: params.locationZone ?? 'global',
255
+ duration: String(duration),
256
+ targetExecutorType: 'agent',
257
+ verificationMode,
258
+ ...(verificationCriteria ? { verificationCriteria } : {}),
259
+ ...(params.verifierAddress ? { verifierAddress: params.verifierAddress } : {}),
260
+ requiredCapabilities,
261
+ rootHash,
262
+ ...(wrappedKeys ? { wrappedKeys } : {}),
263
+ });
264
+ // The tx must go to the escrow and chain checked above: a backend whose
265
+ // posting chain moved in between would otherwise have it signed blind.
266
+ if ((built.chain !== undefined && built.chain !== postingChain) || (built.chainId !== undefined && Number(built.chainId) !== entry.chainId)) {
267
+ throw new ApiError(409, `The backend built this task for ${built.chain} (chain ${built.chainId}), not ${postingChain}: its posting chain changed. Nothing was sent; try again.`, undefined, 'POSTING_CHAIN_CHANGED');
268
+ }
269
+ if (built.unsignedTx.to.toLowerCase() !== escrow.toLowerCase()) {
270
+ throw new ApiError(409, `The backend built this task for ${built.unsignedTx.to}, not the ${postingChain} escrow ${escrow}. Nothing was sent.`, undefined, 'ESCROW_MISMATCH');
271
+ }
272
+ const timeoutMs = opts.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS;
273
+ // createTask pulls an ERC-20 with transferFrom: approve the escrow first.
274
+ const nonce = isNative ? undefined : await ensureAllowance(signer, token, escrow, amount, { timeoutMs });
275
+ const indexParams = {
276
+ txHash: '',
277
+ taskHash,
278
+ rootHash,
279
+ ...(wrappedKeys ? { wrappedKeys } : {}),
280
+ privacy,
281
+ ...(privacy === 'public' ? { publicBrief: params.instructions.slice(0, 4000) } : {}),
282
+ verificationMode,
283
+ ...(verificationCriteria ? { verificationCriteria } : {}),
284
+ ...(params.verifierAddress ? { verifierAddress: params.verifierAddress } : {}),
285
+ requiredCapabilities,
286
+ ...(params.targetExecutor ? { targetExecutor: params.targetExecutor } : {}),
287
+ };
288
+ let txHash;
289
+ try {
290
+ ({ hash: txHash } = await sendAndWait(signer, { to: built.unsignedTx.to, data: built.unsignedTx.data }, {
291
+ value: isNative ? amount : undefined,
292
+ nonce,
293
+ timeoutMs,
294
+ onSent: (hash) => opts.onFunded?.({ txHash: hash, taskHash, indexParams: { ...indexParams, txHash: hash } }),
295
+ unconfirmedHint: (hash) => `If it confirms, call indexTask() with txHash '${hash}' to list the task; do not fund it again.`,
296
+ }));
297
+ }
298
+ catch (err) {
299
+ if (err instanceof UnconfirmedTransactionError) {
300
+ const out = new ApiError(0, err.message, { indexParams: { ...indexParams, txHash: err.hash } }, 'UNCONFIRMED');
301
+ out.txHash = err.hash;
302
+ throw out;
303
+ }
304
+ throw err;
305
+ }
306
+ indexParams.txHash = txHash;
307
+ let indexed;
308
+ try {
309
+ indexed = await this.indexTaskPatiently(indexParams);
310
+ }
311
+ catch (err) {
312
+ const e = err;
313
+ const out = new ApiError(e.status ?? 0, `${e.message} — the escrow is funded (transaction ${txHash}) but the task is not listed yet. Call indexTask() with err.body.indexParams to list it, or cancelAndRefund() it; do not post it again.`, { indexParams }, e.code);
314
+ out.txHash = txHash;
315
+ throw out;
316
+ }
317
+ return {
318
+ taskHash,
319
+ ...(indexed.onChainTaskId !== undefined ? { taskId: String(indexed.onChainTaskId) } : {}),
320
+ txHash,
321
+ chain: postingChain,
322
+ chainId: entry.chainId,
323
+ rootHash,
324
+ privacy,
325
+ wrappedTo: wrappedKeys ? Object.keys(wrappedKeys).length : 0,
326
+ ...(aesKey ? { aesKey } : {}),
327
+ };
328
+ }
329
+ /**
330
+ * List a funded task on the market (`POST /api/v1/a2a/tasks/index`), from
331
+ * its funding transaction. Safe to call again for the same task: the
332
+ * backend merges a repeat from the same poster. postTask() calls it; call
333
+ * it yourself to finish a post whose funding confirmed but whose listing
334
+ * failed (the error's `body.indexParams` holds the fields).
335
+ */
336
+ async indexTask(params) {
337
+ return this.req('POST', '/api/v1/a2a/tasks/index', params);
338
+ }
339
+ /** indexTask(), asking again while the backend's RPC has not seen the receipt or the backend is briefly down. */
340
+ async indexTaskPatiently(params) {
341
+ for (let attempt = 1;; attempt++) {
342
+ try {
343
+ return await this.indexTask(params);
344
+ }
345
+ catch (err) {
346
+ const transient = err instanceof ApiError
347
+ ? err.code === 'RECEIPT_NOT_FOUND' || err.status >= 500
348
+ : err instanceof TypeError; // fetch failed: the network, not the request
349
+ if (!transient || attempt >= 4)
350
+ throw err;
351
+ await new Promise((r) => setTimeout(r, 3_000));
352
+ }
353
+ }
354
+ }
355
+ /**
356
+ * Cancel a task no one has taken and get its escrow back: builds
357
+ * cancelTask, checks the signer is on the task's chain, signs and sends it,
358
+ * then takes the task off the market (`POST /tasks/:id/confirm-tx`).
359
+ * `taskId` is the on-chain id (PostedTask.taskId); pass `chain`
360
+ * (PostedTask.chain) too, since ids repeat across chains.
361
+ */
362
+ async cancelAndRefund(taskId, opts = {}) {
363
+ return this.sendRefund(taskId, await this.cancelTask(taskId, opts.chain), 'Cancelling the task', opts);
364
+ }
365
+ /** Reclaim the escrow of a task whose deadline passed undelivered (claimTimeout), signed and sent. */
366
+ async reclaimAfterTimeout(taskId, opts = {}) {
367
+ return this.sendRefund(taskId, await this.claimTimeout(taskId, opts.chain), 'Reclaiming the escrow', opts);
368
+ }
369
+ async sendRefund(taskId, built, what, opts) {
370
+ const { chain, chainId } = built;
371
+ if (!chain || chainId === undefined) {
372
+ throw new ApiError(409, `${what}: the backend did not say which chain the task is on, so it cannot be signed safely here. Nothing was sent.`, built, 'CHAIN_UNKNOWN');
373
+ }
374
+ const tx = built.unsignedTx;
375
+ const signer = opts.signer ?? this.signerOn(chain, what);
376
+ await assertSignerChain(signer, chainId, what);
377
+ let hash;
378
+ try {
379
+ ({ hash } = await sendAndWait(signer, { to: tx.to, data: tx.data }, { timeoutMs: opts.confirmTimeoutMs }));
380
+ }
381
+ catch (err) {
382
+ if (err instanceof UnconfirmedTransactionError) {
383
+ const out = new ApiError(0, `${err.message} Check it before sending another.`, { txHash: err.hash }, 'UNCONFIRMED');
384
+ out.txHash = err.hash;
385
+ throw out;
386
+ }
387
+ throw err;
388
+ }
389
+ return { txHash: hash, chain, chainId, listingClosed: await this.confirmRefund(taskId, hash, chain) };
390
+ }
391
+ /**
392
+ * Tell the backend a refund landed (`POST /api/v1/tasks/:id/confirm-tx`),
393
+ * which checks the receipt and takes the task off the market. Without it a
394
+ * refunded task keeps listing as open until its deadline. Best effort: the
395
+ * money has already moved, so a failure here only reports false.
396
+ */
397
+ async confirmRefund(taskId, txHash, chain) {
398
+ for (let attempt = 1; attempt <= 3; attempt++) {
399
+ try {
400
+ await this.req('POST', `/api/v1/tasks/${taskId}/confirm-tx`, { txHash, chain });
401
+ return true;
402
+ }
403
+ catch (err) {
404
+ // The backend's RPC can lag the receipt the signer just saw.
405
+ if (!(err instanceof ApiError && err.code === 'NOT_CONFIRMED') || attempt === 3)
406
+ return false;
407
+ await new Promise((r) => setTimeout(r, 3_000));
408
+ }
409
+ }
410
+ return false;
411
+ }
145
412
  // ── Agent deployment & management ─────────────────────────────────────────
413
+ /** What deploying an agent costs on this backend, and how to pay it. */
414
+ async getDeployFee() {
415
+ return this.req('GET', '/api/v1/agents/deploy-fee');
416
+ }
146
417
  /**
147
- * Deploy a new agent. The backend generates a wallet, mints an INFT,
148
- * and returns the agent descriptor.
418
+ * Run every check POST /deploy makes before it takes a fee, with nothing
419
+ * paid or saved. Throws the same ApiError the deploy would (400 with field
420
+ * errors, 404 SKILL_NOT_FOUND, 400 INVALID_OWNER_PUBLIC_KEY). Returns false
421
+ * when the backend predates the check and nothing could be checked.
422
+ */
423
+ async validateDeploy(params) {
424
+ const { ownerAddress: _ignored, ...body } = params;
425
+ try {
426
+ await this.req('POST', '/api/v1/agents/deploy/validate', body);
427
+ return true;
428
+ }
429
+ catch (err) {
430
+ if (err instanceof SyntaxError || (err instanceof ApiError && err.status === 404 && err.code !== 'SKILL_NOT_FOUND'))
431
+ return false;
432
+ throw err;
433
+ }
434
+ }
435
+ /**
436
+ * Deploy a new hosted agent. The backend generates its wallet, mints an
437
+ * INFT, starts it, and returns the agent descriptor.
438
+ *
439
+ * Deploying costs a fee (1 USDC on Arc on production; getDeployFee() says).
440
+ * An unspent AgentFactory credit pays first. Otherwise deployAgent() pays
441
+ * only with `{ payFee: true }`, from the configured executor wallet (set
442
+ * `rpcUrls.arc`) or `payer`, which must be the API key's owner. Before it
443
+ * pays it checks the payer's chain, the fee against `maxFeeRaw`, and the
444
+ * request itself, so nothing is paid for a deploy that would be refused.
445
+ *
446
+ * The fee's hash goes to `onFeePaid` as soon as it is sent, and onto any
447
+ * error after that (`err.feeTxHash`): retry with `params.feeTxHash` set to
448
+ * it and nothing is paid twice. A retry whose payment already created one
449
+ * of your agents returns that agent, with `alreadyDeployed: true`.
149
450
  *
150
451
  * @example
151
452
  * const agent = await bb.deployAgent({
@@ -154,13 +455,178 @@ export class BlindMarket {
154
455
  * provider: 'anthropic',
155
456
  * model: 'claude-sonnet-4-5',
156
457
  * apiKey: process.env.ANTHROPIC_API_KEY!,
157
- * ownerAddress: wallet.address,
158
458
  * // Uncompressed, no 0x (`wallet` is an ethers Wallet; its `publicKey` is compressed).
159
459
  * ownerPublicKey: wallet.signingKey.publicKey.slice(2),
160
- * });
460
+ * }, { payFee: true, onFeePaid: (hash) => saveSomewhere(hash) });
461
+ */
462
+ async deployAgent(params, opts = {}) {
463
+ const { ownerAddress: _ignored, feeTxHash: given, ...rest } = params;
464
+ const body = rest;
465
+ const pollMs = opts.pollIntervalMs ?? 5_000;
466
+ // A named payment: the backend may still be waiting for its receipt.
467
+ if (given)
468
+ return this.deployWithFee(body, given, pollMs);
469
+ let terms;
470
+ try {
471
+ terms = await this.getDeployFee();
472
+ }
473
+ catch (err) {
474
+ // A backend from before the fee route: deploy as the SDK always did.
475
+ if (err instanceof SyntaxError || (err instanceof ApiError && err.status === 404))
476
+ return this.postDeploy(body, [], 1, pollMs);
477
+ throw err;
478
+ }
479
+ if (!terms.required)
480
+ return this.postDeploy(body, [], 1, pollMs);
481
+ // An unspent AgentFactory credit pays before anything new is spent. This
482
+ // POST also runs every check the deploy makes: a request it would refuse
483
+ // fails here, before a payment.
484
+ try {
485
+ return await this.postDeploy(body, [], 1, pollMs);
486
+ }
487
+ catch (err) {
488
+ if (!(err instanceof ApiError && err.code === 'NO_DEPLOY_CREDIT'))
489
+ throw err;
490
+ if (!opts.payFee) {
491
+ const cost = terms.method === 'transfer'
492
+ ? `${ethers.formatUnits(BigInt(terms.amountRaw), terms.decimals).replace(/\.0$/, '')} USDC on ${terms.chain}`
493
+ : `a fee through AgentFactory on ${terms.chain}`;
494
+ throw new ApiError(402, `Deploying an agent costs ${cost}. Call deployAgent(params, { payFee: true }) to pay it from your wallet, or pay it yourself and pass params.feeTxHash.`, { terms }, 'DEPLOY_FEE_REQUIRED');
495
+ }
496
+ }
497
+ // Everything checkable is checked before anything is paid.
498
+ if (body.provider !== '0g-compute' && !body.apiKey) {
499
+ throw new ApiError(400, `A ${body.provider} agent needs params.apiKey to call its model. Nothing was paid.`, undefined, 'API_KEY_REQUIRED');
500
+ }
501
+ if (terms.chainId === undefined) {
502
+ throw new ApiError(409, 'This backend does not say which chain its deploy fee is paid on, so it cannot be paid safely from here. Nothing was paid.', { terms }, 'DEPLOY_FEE_CHAIN_UNKNOWN');
503
+ }
504
+ const maxFee = BigInt(opts.maxFeeRaw ?? 1000000n);
505
+ const payer = opts.payer ?? this.signerOn(terms.chain, 'Paying the deploy fee');
506
+ const payerAddress = await payer.getAddress();
507
+ await this.assertFeePayer(payerAddress);
508
+ await assertSignerChain(payer, terms.chainId, 'The deploy fee');
509
+ const timeoutMs = opts.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS;
510
+ if (terms.method === 'transfer') {
511
+ const fee = BigInt(terms.amountRaw);
512
+ this.assertFeeCeiling(fee, maxFee, terms.decimals);
513
+ const data = new ethers.Interface(['function transfer(address to, uint256 amount) returns (bool)'])
514
+ .encodeFunctionData('transfer', [terms.recipient, fee]);
515
+ let hash;
516
+ try {
517
+ ({ hash } = await sendAndWait(payer, { to: terms.token, data }, {
518
+ onSent: opts.onFeePaid,
519
+ timeoutMs,
520
+ unconfirmedHint: (h) => `If it confirms, retry with params.feeTxHash = '${h}' so the fee is not paid twice.`,
521
+ }));
522
+ }
523
+ catch (err) {
524
+ if (err instanceof UnconfirmedTransactionError)
525
+ throw this.withFee(err, err.hash, 'UNCONFIRMED');
526
+ throw err;
527
+ }
528
+ return this.deployWithFee(body, hash, pollMs);
529
+ }
530
+ if (!terms.factory)
531
+ throw new ApiError(503, 'This backend charges through AgentFactory but names no factory address.', { terms }, 'DEPLOY_FEE_UNAVAILABLE');
532
+ const factory = new ethers.Interface(['function deployAgent(uint256 usdcAmount)', 'function deployFeeUsdc() view returns (uint256)', 'function usdc() view returns (address)']);
533
+ const reader = payer.provider;
534
+ const [fee] = factory.decodeFunctionResult('deployFeeUsdc', await reader.call({ to: terms.factory, data: factory.encodeFunctionData('deployFeeUsdc') }));
535
+ const [token] = factory.decodeFunctionResult('usdc', await reader.call({ to: terms.factory, data: factory.encodeFunctionData('usdc') }));
536
+ this.assertFeeCeiling(fee, maxFee, 6);
537
+ const nonce = await ensureAllowance(payer, token, terms.factory, fee, { timeoutMs });
538
+ let factoryTx;
539
+ try {
540
+ ({ hash: factoryTx } = await sendAndWait(payer, { to: terms.factory, data: factory.encodeFunctionData('deployAgent', [0]) }, { nonce, timeoutMs }));
541
+ }
542
+ catch (err) {
543
+ if (err instanceof UnconfirmedTransactionError) {
544
+ throw new ApiError(0, `${err.message} If it confirms, its credit pays for the next deployAgent() call; do not pay again.`, { factoryTxHash: err.hash }, 'UNCONFIRMED');
545
+ }
546
+ throw err;
547
+ }
548
+ // The backend indexes the factory every 15s: the credit lags the payment.
549
+ try {
550
+ return { ...(await this.postDeploy(body, ['NO_DEPLOY_CREDIT'], 20, pollMs)), feeTxHash: factoryTx };
551
+ }
552
+ catch (err) {
553
+ const e = err;
554
+ throw new ApiError(e.status ?? 500, `${e.message} The fee was paid through AgentFactory (transaction ${factoryTx}): its credit stays with your wallet and pays for the next deployAgent() call, so do not pay again.`, err instanceof ApiError ? err.body : undefined, e.code);
555
+ }
556
+ }
557
+ /** Deploy with a fee transaction already paid: wait for the backend to see it, and make a retry safe. */
558
+ async deployWithFee(body, feeTxHash, pollMs) {
559
+ try {
560
+ const agent = await this.postDeploy({ ...body, feeTxHash }, ['DEPLOY_FEE_NOT_FOUND', 'DEPLOY_FEE_IN_USE', 'DEPLOY_FEE_CHECK_FAILED'], 4, pollMs);
561
+ return { ...agent, feeTxHash };
562
+ }
563
+ catch (err) {
564
+ // This payment already created an agent: a retry after a lost response.
565
+ // Return it when it is the caller's.
566
+ if (err instanceof ApiError && err.code === 'DEPLOY_FEE_ALREADY_USED') {
567
+ const agentId = err.body?.error?.agentId;
568
+ const existing = agentId ? await this.ownAgent(agentId).catch(() => null) : null;
569
+ if (existing)
570
+ return { ...existing, feeTxHash, alreadyDeployed: true };
571
+ }
572
+ throw this.withFee(err, feeTxHash);
573
+ }
574
+ }
575
+ /** `agentId` as a DeployedAgent, when the API key's owner owns it; else null. */
576
+ async ownAgent(agentId) {
577
+ const [agent, who] = await Promise.all([this.getAgent(agentId), this.whoami()]);
578
+ const mine = new Set([who.address, ...(who.addresses ?? [])].map((a) => String(a).toLowerCase()));
579
+ if (!agent?.ownerAddress || !mine.has(agent.ownerAddress.toLowerCase()))
580
+ return null;
581
+ const { id, name, walletAddress, publicKey, inftTokenId, status } = agent;
582
+ return { id, name, walletAddress, publicKey, status, ...(inftTokenId !== undefined ? { inftTokenId } : {}) };
583
+ }
584
+ /**
585
+ * An error after the fee was paid, carrying the payment: `feeTxHash` on the
586
+ * error and in its body, and the message says how to reuse it. The
587
+ * backend's code, status and envelope are kept.
161
588
  */
162
- async deployAgent(params) {
163
- return this.req('POST', '/api/v1/agents/deploy', params);
589
+ withFee(err, feeTxHash, fallbackCode) {
590
+ const e = err;
591
+ const spent = err instanceof ApiError && ['DEPLOY_FEE_ALREADY_USED', 'DEPLOY_FEE_NOT_PAID', 'DEPLOY_FEE_REVERTED'].includes(err.code ?? '');
592
+ const message = spent || e.message.includes(feeTxHash)
593
+ ? e.message
594
+ : `${e.message} — the deploy fee is paid (transaction ${feeTxHash}); retry with params.feeTxHash = '${feeTxHash}' so it is not paid twice.`;
595
+ const body = e.body && typeof e.body === 'object' ? { ...e.body, feeTxHash } : { feeTxHash };
596
+ const out = new ApiError(e.status ?? 0, message, body, e.code ?? fallbackCode);
597
+ if (err instanceof ApiError && err.reason)
598
+ out.reason = err.reason;
599
+ out.feeTxHash = feeTxHash;
600
+ return out;
601
+ }
602
+ assertFeeCeiling(fee, maxFee, decimals) {
603
+ if (fee <= maxFee)
604
+ return;
605
+ const fmt = (v) => ethers.formatUnits(v, decimals).replace(/\.0$/, '');
606
+ throw new ApiError(402, `The deploy fee is ${fmt(fee)} USDC, above your limit of ${fmt(maxFee)}. Nothing was paid. Raise opts.maxFeeRaw to pay it.`, { feeRaw: fee.toString(), maxFeeRaw: maxFee.toString() }, 'DEPLOY_FEE_ABOVE_MAX');
607
+ }
608
+ /** POST /agents/deploy, asking again while the backend answers one of `retryCodes`. */
609
+ async postDeploy(body, retryCodes, attempts, pollMs) {
610
+ for (let i = 1;; i++) {
611
+ try {
612
+ return await this.req('POST', '/api/v1/agents/deploy', body);
613
+ }
614
+ catch (err) {
615
+ if (!(err instanceof ApiError) || !retryCodes.includes(err.code ?? '') || i >= attempts)
616
+ throw err;
617
+ await new Promise((r) => setTimeout(r, pollMs));
618
+ }
619
+ }
620
+ }
621
+ /** The configured executor as a signer on `chain`. */
622
+ signerOn(chain, what) {
623
+ if (!this.executor) {
624
+ throw new ApiError(400, `${what} needs a signer: set BlindMarketConfig.executor, or pass one in the options.`, undefined, 'NO_SIGNER');
625
+ }
626
+ const rpc = this.executor.rpcUrls[chain];
627
+ if (!rpc)
628
+ throw new ApiError(400, `${what} happens on ${chain}, but no RPC is configured for it — set rpcUrls.${chain}.`, undefined, 'NO_RPC');
629
+ return new ethers.Wallet(this.executor.privateKey, new ethers.JsonRpcProvider(rpc));
164
630
  }
165
631
  /**
166
632
  * One-shot executor registration in the A2A marketplace.
@@ -257,6 +723,30 @@ export class BlindMarket {
257
723
  }
258
724
  return true;
259
725
  }
726
+ /**
727
+ * Before a spend: throw 409 OWNER_MISMATCH unless `address` is a wallet the
728
+ * backend will credit the spend to. `exact` needs the API key's own address
729
+ * (a task is posted as that wallet); otherwise any wallet linked to it
730
+ * counts, as the deploy fee check does. Unlike assertOwnerKey this fails
731
+ * closed: money never moves on an unchecked wallet.
732
+ */
733
+ async assertSpender(address, what, exact) {
734
+ let who;
735
+ try {
736
+ who = await this.whoami();
737
+ }
738
+ catch (err) {
739
+ throw new ApiError(err instanceof ApiError ? err.status : 503, `${what}: could not check which wallet this API key belongs to (${err.message}). Nothing was sent.`, undefined, 'OWNER_UNCHECKED');
740
+ }
741
+ const allowed = new Set([who.address, ...(exact ? [] : who.addresses ?? [])].filter((a) => typeof a === 'string').map((a) => a.toLowerCase()));
742
+ if (!allowed.has(address.toLowerCase())) {
743
+ throw new ApiError(409, `This API key belongs to ${who.address} but the signer is ${address}. Nothing was sent. ` +
744
+ `${what} counts only from the API key's own wallet: sign with that wallet, or mint an API key signed in as this one.`, undefined, 'OWNER_MISMATCH');
745
+ }
746
+ }
747
+ assertFeePayer(address) {
748
+ return this.assertSpender(address, 'A deploy fee', false);
749
+ }
260
750
  /** List deployed agents, optionally filtered by owner address. */
261
751
  async listAgents(ownerAddress) {
262
752
  const qs = ownerAddress ? `?owner=${ownerAddress}` : '';
@@ -431,6 +921,16 @@ export class BlindMarket {
431
921
  return { ...(await this.finalize(taskId)), submitTxHash };
432
922
  }
433
923
  }
924
+ /**
925
+ * Approve or reject the delivered result of a task you posted with
926
+ * `verificationMode: 'manual'` (`POST /api/v1/a2a/tasks/:hash/verify`).
927
+ * Approving settles the escrow to the worker (90%); rejecting fails the
928
+ * round, and the worker may resubmit before the deadline. Only the poster
929
+ * can review, and only once the task is `submitted`.
930
+ */
931
+ async reviewResult(taskHash, review) {
932
+ return this.req('POST', `/api/v1/a2a/tasks/${encodeURIComponent(taskHash)}/verify`, review);
933
+ }
434
934
  /** Get tasks posted by the authenticated user. */
435
935
  async getPostedTasks() {
436
936
  return this.req('GET', '/api/v1/a2a/tasks/posted');
@@ -470,7 +970,11 @@ export class BlindMarket {
470
970
  return this.req('GET', `/api/v1/reputation/leaderboard?limit=${limit}`);
471
971
  }
472
972
  // ── Storage ─────────────────────────────────────────────────────────────
473
- /** Upload an encrypted blob to 0G Storage. */
973
+ /**
974
+ * Upload a blob to 0G Storage. `data` is the bytes as **base64**: the
975
+ * backend base64-decodes it. (The type once said Hex; a hex string sent
976
+ * here uploads the wrong bytes.)
977
+ */
474
978
  async uploadBlob(data) {
475
979
  return this.req('POST', '/api/v1/storage/upload', { data });
476
980
  }