@blindmarket/sdk 0.6.4 → 0.8.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,15 @@
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
+ import { checkEscrowCall, evidenceHashOf, taskIdOf } from './escrowCalls.js';
6
+ /** A whole number of base units from a string or bigint; throws 400 INVALID_AMOUNT otherwise. */
7
+ function wholeNumber(value, name) {
8
+ if (typeof value === 'bigint')
9
+ return value;
10
+ if (typeof value === 'string' && /^\d+$/.test(value))
11
+ return BigInt(value);
12
+ 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
13
  }
17
14
  // ── Main client ─────────────────────────────────────────────────────────────
18
15
  /**
@@ -86,7 +83,10 @@ export class BlindMarket {
86
83
  });
87
84
  const json = await res.json();
88
85
  if (!json.success) {
89
- throw new ApiError(res.status, json.error?.message ?? `HTTP ${res.status}`, json, json.error?.code);
86
+ const err = new ApiError(res.status, json.error?.message ?? `HTTP ${res.status}`, json, json.error?.code);
87
+ if (typeof json.error?.reason === 'string')
88
+ err.reason = json.error.reason;
89
+ throw err;
90
90
  }
91
91
  return json.data;
92
92
  }
@@ -100,7 +100,11 @@ export class BlindMarket {
100
100
  return this.req('GET', '/api/v1/stats');
101
101
  }
102
102
  // ── Task lifecycle ──────────────────────────────────────────────────────
103
- /** List open tasks (human-readable). */
103
+ /**
104
+ * List open tasks from the legacy 0G TaskRegistry (numeric ids on the 0G
105
+ * escrow). Tasks escrowed on Base or Arc are not in it: browseA2ATasks()
106
+ * lists the work agents can take.
107
+ */
104
108
  async listTasks(limit = 20) {
105
109
  const { tasks } = await this.req('GET', `/api/v1/tasks?limit=${limit}`);
106
110
  return tasks;
@@ -125,16 +129,23 @@ export class BlindMarket {
125
129
  return this.req('POST', `/api/v1/tasks/${taskId}/assign`, { worker });
126
130
  }
127
131
  /**
128
- * Build an unsigned `cancelTask` transaction.
132
+ * Build an unsigned `cancelTask` transaction (the refund of a task no one
133
+ * has taken). `chain`/`chainId` name where to send it. cancelAndRefund()
134
+ * builds, signs and sends it for you.
129
135
  */
130
- async cancelTask(taskId) {
131
- return this.req('POST', `/api/v1/tasks/${taskId}/cancel`);
136
+ async cancelTask(taskId, chain) {
137
+ // Task ids collide across chains: naming the chain builds for that one.
138
+ return this.req('POST', `/api/v1/tasks/${taskId}/cancel`, chain ? { chain } : undefined);
132
139
  }
133
140
  /**
134
- * Build an unsigned `claimTimeout` transaction.
141
+ * Build an unsigned `claimTimeout` transaction (the refund of a task whose
142
+ * deadline passed). reclaimAfterTimeout() builds, signs and sends it for you.
143
+ * `outcome` says what it will do: on work delivered before the deadline and
144
+ * never judged, the escrow sends the task for review ('escalate') instead
145
+ * of refunding it, and `message` explains.
135
146
  */
136
- async claimTimeout(taskId) {
137
- return this.req('POST', `/api/v1/tasks/${taskId}/timeout`);
147
+ async claimTimeout(taskId, chain) {
148
+ return this.req('POST', `/api/v1/tasks/${taskId}/timeout`, chain ? { chain } : undefined);
138
149
  }
139
150
  /**
140
151
  * Build an unsigned `submitEvidence` transaction.
@@ -142,10 +153,373 @@ export class BlindMarket {
142
153
  async submitEvidence(params) {
143
154
  return this.req('POST', '/api/v1/submissions/submit', params);
144
155
  }
156
+ // ── Posting a task end to end ─────────────────────────────────────────────
157
+ /**
158
+ * Where new tasks are posted and what each chain settles in
159
+ * (`GET /health/settlement`): no auth, no RPC reads on the backend.
160
+ */
161
+ async getSettlement() {
162
+ return this.req('GET', '/health/settlement');
163
+ }
164
+ /**
165
+ * `chain`'s entry in /health/settlement, with its escrow: every transaction
166
+ * the backend builds for this client to sign must target that escrow.
167
+ * Throws 409 CHAIN_UNKNOWN when the backend lists no escrow for it.
168
+ */
169
+ async settlementEntry(chain, what, settlement) {
170
+ const { chains } = settlement ?? await this.getSettlement();
171
+ const entry = chains.find((c) => c.chain === chain);
172
+ if (!entry?.escrowAddress || !Number.isInteger(entry.chainId)) {
173
+ throw new ApiError(409, `${what}: the backend lists no escrow for ${chain} (GET /health/settlement), so a transaction built for it cannot be checked before signing. Nothing was sent.`, undefined, 'CHAIN_UNKNOWN');
174
+ }
175
+ return entry;
176
+ }
177
+ /**
178
+ * Post a task end to end, from the API key's own wallet: encrypt the brief
179
+ * (unless public) and wrap its key to the posting chain's executors, upload
180
+ * it, build createTask, approve the escrow for the amount when the token is
181
+ * an ERC-20, fund the escrow, and list the task (`POST /a2a/tasks/index`).
182
+ *
183
+ * The wallet signs locally, on the backend's posting chain (Arc on
184
+ * production, where gas is paid in USDC). Before anything is sent it checks
185
+ * the signer is the API key's owner, that its RPC is on the posting chain,
186
+ * that the wallet holds the amount, and that the backend built exactly this
187
+ * createTask (task hash, token, amount, zone, duration) for the escrow it
188
+ * advertises, with no other value: 409 ESCROW_MISMATCH / TX_MISMATCH
189
+ * otherwise. Only the tx's to and data are signed. The funding hash goes to `onFunded` as soon as
190
+ * it is sent; an error after that carries it as `err.txHash`, and
191
+ * indexTask() lists the funded task without paying again.
192
+ *
193
+ * @example
194
+ * const task = await bb.postTask(
195
+ * { instructions: 'Summarise this paper in 5 bullets: …', amountRaw: '2000000' }, // 2 USDC
196
+ * { onFunded: ({ txHash }) => saveSomewhere(txHash) },
197
+ * );
198
+ */
199
+ async postTask(params, opts = {}) {
200
+ const amount = wholeNumber(params.amountRaw, 'amountRaw');
201
+ if (amount <= 0n)
202
+ throw new ApiError(400, 'amountRaw must be above 0. Nothing was sent.', undefined, 'INVALID_AMOUNT');
203
+ if (opts.maxAmountRaw !== undefined && amount > BigInt(opts.maxAmountRaw)) {
204
+ throw new ApiError(402, `The escrow of ${amount} is above your limit of ${opts.maxAmountRaw}. Nothing was sent.`, undefined, 'AMOUNT_ABOVE_MAX');
205
+ }
206
+ const duration = params.durationSeconds ?? 86_400;
207
+ if (!Number.isInteger(duration) || duration < 3_600 || duration > 90 * 86_400) {
208
+ 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');
209
+ }
210
+ const privacy = params.privacy ?? 'private';
211
+ const locationZone = params.locationZone ?? 'global';
212
+ const verificationMode = params.verificationMode ?? 'auto';
213
+ const verificationCriteria = params.verificationCriteria
214
+ ?? (verificationMode === 'auto' ? { min_length: 10, pass_threshold: 60 } : undefined);
215
+ const requiredCapabilities = params.requiredCapabilities ?? [];
216
+ // Where the escrow is funded, and in what.
217
+ const { postingChain, chains } = await this.getSettlement();
218
+ const entry = chains.find((c) => c.chain === postingChain);
219
+ if (!postingChain || !entry || !entry.escrowAddress || !entry.token.address) {
220
+ 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');
221
+ }
222
+ const escrow = entry.escrowAddress;
223
+ const token = entry.token.address;
224
+ const isNative = entry.token.kind === 'native';
225
+ const signer = opts.signer ?? this.signerOn(postingChain, 'Funding the escrow');
226
+ const poster = await signer.getAddress();
227
+ await this.assertSpender(poster, 'A task', true);
228
+ await assertSignerChain(signer, entry.chainId, `Funding the escrow on ${postingChain}`);
229
+ if (!isNative) {
230
+ const balance = await tokenBalance(signer, token, poster);
231
+ if (balance < amount) {
232
+ const fmt = (v) => ethers.formatUnits(v, entry.token.decimals);
233
+ throw new ApiError(402, `${poster} holds ${fmt(balance)} ${entry.token.symbol} on ${postingChain}; the escrow needs ${fmt(amount)}. Nothing was sent.`, undefined, 'INSUFFICIENT_BALANCE');
234
+ }
235
+ }
236
+ // The brief: plaintext, or encrypted to the executors that can take it.
237
+ const plaintext = new TextEncoder().encode(params.instructions);
238
+ let blob;
239
+ let wrappedKeys;
240
+ let aesKey;
241
+ if (privacy === 'public') {
242
+ blob = plaintext;
243
+ }
244
+ else {
245
+ const qs = new URLSearchParams({ capabilities: requiredCapabilities.join(','), chain: postingChain });
246
+ const { executors } = await this.req('GET', `/api/v1/a2a/executors?${qs}`);
247
+ let targets = executors.filter((e) => typeof e.publicKey === 'string' && e.publicKey.length > 0);
248
+ if (params.targetExecutor) {
249
+ const want = params.targetExecutor.toLowerCase();
250
+ targets = targets.filter((e) => e.address.toLowerCase() === want);
251
+ if (targets.length === 0) {
252
+ 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');
253
+ }
254
+ }
255
+ if (targets.length > 200) {
256
+ 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');
257
+ }
258
+ const key = await generateAesKey();
259
+ blob = await aesEncrypt(plaintext, key);
260
+ wrappedKeys = {};
261
+ for (const e of targets) {
262
+ try {
263
+ wrappedKeys[e.address.toLowerCase()] = bytesToHex(await eciesEncrypt(key, e.publicKey));
264
+ }
265
+ catch { /* a malformed public key: that executor can't be wrapped to */ }
266
+ }
267
+ if (Object.keys(wrappedKeys).length === 0) {
268
+ 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');
269
+ }
270
+ aesKey = bytesToHex(key);
271
+ }
272
+ const taskHash = `0x${bytesToHex(await sha256(blob))}`;
273
+ const { rootHash } = await this.uploadBlob(ethers.encodeBase64(blob));
274
+ const built = await this.createTask({
275
+ taskHash: taskHash,
276
+ token: token,
277
+ amount: amount.toString(),
278
+ locationZone,
279
+ duration: String(duration),
280
+ targetExecutorType: 'agent',
281
+ verificationMode,
282
+ ...(verificationCriteria ? { verificationCriteria } : {}),
283
+ ...(params.verifierAddress ? { verifierAddress: params.verifierAddress } : {}),
284
+ requiredCapabilities,
285
+ rootHash,
286
+ ...(wrappedKeys ? { wrappedKeys } : {}),
287
+ });
288
+ // The tx must go to the escrow and chain checked above: a backend whose
289
+ // posting chain moved in between would otherwise have it signed blind.
290
+ if ((built.chain !== undefined && built.chain !== postingChain) || (built.chainId !== undefined && Number(built.chainId) !== entry.chainId)) {
291
+ 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');
292
+ }
293
+ // And it must be exactly this createTask: the escrow, the task hash, the
294
+ // token, the amount, the zone and the duration asked for (a verifier
295
+ // commits through createTaskWithVerifier). Only its to and data are signed;
296
+ // the value is the amount computed here.
297
+ const withVerifier = verificationMode === 'agent' && !!params.verifierAddress && params.verifierAddress.toLowerCase() !== ethers.ZeroAddress;
298
+ const createCall = checkEscrowCall(built.unsignedTx, {
299
+ escrow,
300
+ fn: withVerifier ? 'createTaskWithVerifier' : 'createTask',
301
+ args: (a) => String(a[0]).toLowerCase() === taskHash.toLowerCase()
302
+ && String(a[1]).toLowerCase() === token.toLowerCase()
303
+ && a[2] === amount
304
+ && a[4] === locationZone
305
+ && a[5] === BigInt(duration)
306
+ && (!withVerifier || String(a[6]).toLowerCase() === params.verifierAddress.toLowerCase()),
307
+ value: isNative ? amount : 0n,
308
+ chainId: entry.chainId,
309
+ }, `Funding the escrow on ${postingChain}`);
310
+ const timeoutMs = opts.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS;
311
+ // createTask pulls an ERC-20 with transferFrom: approve the escrow first.
312
+ const nonce = isNative ? undefined : await ensureAllowance(signer, token, escrow, amount, { timeoutMs });
313
+ const indexParams = {
314
+ txHash: '',
315
+ taskHash,
316
+ rootHash,
317
+ ...(wrappedKeys ? { wrappedKeys } : {}),
318
+ privacy,
319
+ ...(privacy === 'public' ? { publicBrief: params.instructions.slice(0, 4000) } : {}),
320
+ verificationMode,
321
+ ...(verificationCriteria ? { verificationCriteria } : {}),
322
+ ...(params.verifierAddress ? { verifierAddress: params.verifierAddress } : {}),
323
+ requiredCapabilities,
324
+ ...(params.targetExecutor ? { targetExecutor: params.targetExecutor } : {}),
325
+ };
326
+ let txHash;
327
+ try {
328
+ ({ hash: txHash } = await sendAndWait(signer, createCall, {
329
+ value: isNative ? amount : undefined,
330
+ nonce,
331
+ timeoutMs,
332
+ onSent: (hash) => opts.onFunded?.({ txHash: hash, taskHash, indexParams: { ...indexParams, txHash: hash } }),
333
+ unconfirmedHint: (hash) => `If it confirms, call indexTask() with txHash '${hash}' to list the task; do not fund it again.`,
334
+ }));
335
+ }
336
+ catch (err) {
337
+ if (err instanceof UnconfirmedTransactionError) {
338
+ const out = new ApiError(0, err.message, { indexParams: { ...indexParams, txHash: err.hash } }, 'UNCONFIRMED');
339
+ out.txHash = err.hash;
340
+ throw out;
341
+ }
342
+ throw err;
343
+ }
344
+ indexParams.txHash = txHash;
345
+ let indexed;
346
+ try {
347
+ indexed = await this.indexTaskPatiently(indexParams);
348
+ }
349
+ catch (err) {
350
+ const e = err;
351
+ 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);
352
+ out.txHash = txHash;
353
+ throw out;
354
+ }
355
+ return {
356
+ taskHash,
357
+ ...(indexed.onChainTaskId !== undefined ? { taskId: String(indexed.onChainTaskId) } : {}),
358
+ txHash,
359
+ chain: postingChain,
360
+ chainId: entry.chainId,
361
+ rootHash,
362
+ privacy,
363
+ wrappedTo: wrappedKeys ? Object.keys(wrappedKeys).length : 0,
364
+ ...(aesKey ? { aesKey } : {}),
365
+ };
366
+ }
367
+ /**
368
+ * List a funded task on the market (`POST /api/v1/a2a/tasks/index`), from
369
+ * its funding transaction. Safe to call again for the same task: the
370
+ * backend merges a repeat from the same poster. postTask() calls it; call
371
+ * it yourself to finish a post whose funding confirmed but whose listing
372
+ * failed (the error's `body.indexParams` holds the fields).
373
+ */
374
+ async indexTask(params) {
375
+ return this.req('POST', '/api/v1/a2a/tasks/index', params);
376
+ }
377
+ /** indexTask(), asking again while the backend's RPC has not seen the receipt or the backend is briefly down. */
378
+ async indexTaskPatiently(params) {
379
+ for (let attempt = 1;; attempt++) {
380
+ try {
381
+ return await this.indexTask(params);
382
+ }
383
+ catch (err) {
384
+ const transient = err instanceof ApiError
385
+ ? err.code === 'RECEIPT_NOT_FOUND' || err.status >= 500
386
+ : err instanceof TypeError; // fetch failed: the network, not the request
387
+ if (!transient || attempt >= 4)
388
+ throw err;
389
+ await new Promise((r) => setTimeout(r, 3_000));
390
+ }
391
+ }
392
+ }
393
+ /**
394
+ * Cancel a task no one has taken and get its escrow back: builds
395
+ * cancelTask, checks the signer is on the task's chain, signs and sends it,
396
+ * then takes the task off the market (`POST /tasks/:id/confirm-tx`).
397
+ * `taskId` is the on-chain id (PostedTask.taskId); pass `chain`
398
+ * (PostedTask.chain) too, since ids repeat across chains.
399
+ *
400
+ * Only a zero-value `cancelTask(taskId)` on the escrow /health/settlement
401
+ * lists for the chain is signed (to and data only), and only on the chain
402
+ * you named: 409 ESCROW_MISMATCH, TX_MISMATCH, CHAIN_MISMATCH or
403
+ * CHAIN_UNKNOWN otherwise, with nothing sent. reclaimAfterTimeout() does
404
+ * the same for `claimTimeout(taskId)`.
405
+ */
406
+ async cancelAndRefund(taskId, opts = {}) {
407
+ return this.sendRefund(taskId, await this.cancelTask(taskId, opts.chain), 'cancelTask', 'Cancelling the task', opts);
408
+ }
409
+ /**
410
+ * Reclaim the escrow of a task whose deadline passed undelivered
411
+ * (claimTimeout), signed and sent. On work delivered before the deadline
412
+ * and never judged, the escrow sends the task for review instead and
413
+ * refunds nothing: the result's outcome is then 'escalate'.
414
+ */
415
+ async reclaimAfterTimeout(taskId, opts = {}) {
416
+ return this.sendRefund(taskId, await this.claimTimeout(taskId, opts.chain), 'claimTimeout', 'Reclaiming the escrow', opts);
417
+ }
418
+ /**
419
+ * Sign the refund the backend built, once it is checked to be exactly
420
+ * `fn(taskId)` on the escrow of the chain it names (the one the caller
421
+ * named, when it named one), with no value. Only its to and data are signed.
422
+ */
423
+ async sendRefund(taskId, built, fn, what, opts) {
424
+ const { chain, chainId } = built;
425
+ if (!chain || chainId === undefined) {
426
+ 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');
427
+ }
428
+ if (opts.chain && chain !== opts.chain) {
429
+ throw new ApiError(409, `${what}: you asked for task ${taskId} on ${opts.chain}, but the backend built the refund for ${chain}. Nothing was sent.`, built, 'CHAIN_MISMATCH');
430
+ }
431
+ const entry = await this.settlementEntry(chain, what);
432
+ if (Number(chainId) !== entry.chainId) {
433
+ throw new ApiError(409, `${what}: the backend built the refund for chain ${chainId}, but lists ${chain} as chain ${entry.chainId}. Nothing was sent.`, built, 'CHAIN_MISMATCH');
434
+ }
435
+ const id = taskIdOf(taskId);
436
+ const call = checkEscrowCall(built.unsignedTx, {
437
+ escrow: entry.escrowAddress,
438
+ fn,
439
+ args: (a) => id !== undefined && a[0] === id,
440
+ chainId: entry.chainId,
441
+ }, what);
442
+ const signer = opts.signer ?? this.signerOn(chain, what);
443
+ await assertSignerChain(signer, entry.chainId, what);
444
+ let hash;
445
+ try {
446
+ ({ hash } = await sendAndWait(signer, call, { timeoutMs: opts.confirmTimeoutMs }));
447
+ }
448
+ catch (err) {
449
+ if (err instanceof UnconfirmedTransactionError) {
450
+ const out = new ApiError(0, `${err.message} Check it before sending another.`, { txHash: err.hash }, 'UNCONFIRMED');
451
+ out.txHash = err.hash;
452
+ throw out;
453
+ }
454
+ throw err;
455
+ }
456
+ const confirmed = await this.confirmRefund(taskId, hash, chain);
457
+ // The receipt is the authority; the build's outcome covers a backend that
458
+ // could not confirm it.
459
+ const outcome = confirmed.escalated ? 'escalate' : built.outcome;
460
+ return { txHash: hash, chain, chainId, listingClosed: confirmed.closed, ...(outcome ? { outcome } : {}) };
461
+ }
462
+ /**
463
+ * Tell the backend a refund landed (`POST /api/v1/tasks/:id/confirm-tx`),
464
+ * which checks the receipt and takes the task off the market. Without it a
465
+ * refunded task keeps listing as open until its deadline. Best effort: the
466
+ * money has already moved, so a failure here only reports it not closed.
467
+ * A claim that sent the task for review closes nothing (escalated).
468
+ */
469
+ async confirmRefund(taskId, txHash, chain) {
470
+ for (let attempt = 1; attempt <= 3; attempt++) {
471
+ try {
472
+ const res = await this.req('POST', `/api/v1/tasks/${taskId}/confirm-tx`, { txHash, chain });
473
+ const escalated = res?.escalated === true;
474
+ return { closed: !escalated, escalated };
475
+ }
476
+ catch (err) {
477
+ // The backend's RPC can lag the receipt the signer just saw.
478
+ if (!(err instanceof ApiError && err.code === 'NOT_CONFIRMED') || attempt === 3)
479
+ return { closed: false, escalated: false };
480
+ await new Promise((r) => setTimeout(r, 3_000));
481
+ }
482
+ }
483
+ return { closed: false, escalated: false };
484
+ }
145
485
  // ── Agent deployment & management ─────────────────────────────────────────
486
+ /** What deploying an agent costs on this backend, and how to pay it. */
487
+ async getDeployFee() {
488
+ return this.req('GET', '/api/v1/agents/deploy-fee');
489
+ }
490
+ /**
491
+ * Run every check POST /deploy makes before it takes a fee, with nothing
492
+ * paid or saved. Throws the same ApiError the deploy would (400 with field
493
+ * errors, 404 SKILL_NOT_FOUND, 400 INVALID_OWNER_PUBLIC_KEY). Returns false
494
+ * when the backend predates the check and nothing could be checked.
495
+ */
496
+ async validateDeploy(params) {
497
+ const { ownerAddress: _ignored, ...body } = params;
498
+ try {
499
+ await this.req('POST', '/api/v1/agents/deploy/validate', body);
500
+ return true;
501
+ }
502
+ catch (err) {
503
+ if (err instanceof SyntaxError || (err instanceof ApiError && err.status === 404 && err.code !== 'SKILL_NOT_FOUND'))
504
+ return false;
505
+ throw err;
506
+ }
507
+ }
146
508
  /**
147
- * Deploy a new agent. The backend generates a wallet, mints an INFT,
148
- * and returns the agent descriptor.
509
+ * Deploy a new hosted agent. The backend generates its wallet, mints an
510
+ * INFT, starts it, and returns the agent descriptor.
511
+ *
512
+ * Deploying costs a fee (1 USDC on Arc on production; getDeployFee() says).
513
+ * An unspent AgentFactory credit pays first. Otherwise deployAgent() pays
514
+ * only with `{ payFee: true }`, from the configured executor wallet (set
515
+ * `rpcUrls.arc`) or `payer`, which must be the API key's owner. Before it
516
+ * pays it checks the payer's chain, the fee against `maxFeeRaw`, and the
517
+ * request itself, so nothing is paid for a deploy that would be refused.
518
+ *
519
+ * The fee's hash goes to `onFeePaid` as soon as it is sent, and onto any
520
+ * error after that (`err.feeTxHash`): retry with `params.feeTxHash` set to
521
+ * it and nothing is paid twice. A retry whose payment already created one
522
+ * of your agents returns that agent, with `alreadyDeployed: true`.
149
523
  *
150
524
  * @example
151
525
  * const agent = await bb.deployAgent({
@@ -154,13 +528,178 @@ export class BlindMarket {
154
528
  * provider: 'anthropic',
155
529
  * model: 'claude-sonnet-4-5',
156
530
  * apiKey: process.env.ANTHROPIC_API_KEY!,
157
- * ownerAddress: wallet.address,
158
531
  * // Uncompressed, no 0x (`wallet` is an ethers Wallet; its `publicKey` is compressed).
159
532
  * ownerPublicKey: wallet.signingKey.publicKey.slice(2),
160
- * });
533
+ * }, { payFee: true, onFeePaid: (hash) => saveSomewhere(hash) });
161
534
  */
162
- async deployAgent(params) {
163
- return this.req('POST', '/api/v1/agents/deploy', params);
535
+ async deployAgent(params, opts = {}) {
536
+ const { ownerAddress: _ignored, feeTxHash: given, ...rest } = params;
537
+ const body = rest;
538
+ const pollMs = opts.pollIntervalMs ?? 5_000;
539
+ // A named payment: the backend may still be waiting for its receipt.
540
+ if (given)
541
+ return this.deployWithFee(body, given, pollMs);
542
+ let terms;
543
+ try {
544
+ terms = await this.getDeployFee();
545
+ }
546
+ catch (err) {
547
+ // A backend from before the fee route: deploy as the SDK always did.
548
+ if (err instanceof SyntaxError || (err instanceof ApiError && err.status === 404))
549
+ return this.postDeploy(body, [], 1, pollMs);
550
+ throw err;
551
+ }
552
+ if (!terms.required)
553
+ return this.postDeploy(body, [], 1, pollMs);
554
+ // An unspent AgentFactory credit pays before anything new is spent. This
555
+ // POST also runs every check the deploy makes: a request it would refuse
556
+ // fails here, before a payment.
557
+ try {
558
+ return await this.postDeploy(body, [], 1, pollMs);
559
+ }
560
+ catch (err) {
561
+ if (!(err instanceof ApiError && err.code === 'NO_DEPLOY_CREDIT'))
562
+ throw err;
563
+ if (!opts.payFee) {
564
+ const cost = terms.method === 'transfer'
565
+ ? `${ethers.formatUnits(BigInt(terms.amountRaw), terms.decimals).replace(/\.0$/, '')} USDC on ${terms.chain}`
566
+ : `a fee through AgentFactory on ${terms.chain}`;
567
+ 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');
568
+ }
569
+ }
570
+ // Everything checkable is checked before anything is paid.
571
+ if (body.provider !== '0g-compute' && !body.apiKey) {
572
+ throw new ApiError(400, `A ${body.provider} agent needs params.apiKey to call its model. Nothing was paid.`, undefined, 'API_KEY_REQUIRED');
573
+ }
574
+ if (terms.chainId === undefined) {
575
+ 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');
576
+ }
577
+ const maxFee = BigInt(opts.maxFeeRaw ?? 1000000n);
578
+ const payer = opts.payer ?? this.signerOn(terms.chain, 'Paying the deploy fee');
579
+ const payerAddress = await payer.getAddress();
580
+ await this.assertFeePayer(payerAddress);
581
+ await assertSignerChain(payer, terms.chainId, 'The deploy fee');
582
+ const timeoutMs = opts.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS;
583
+ if (terms.method === 'transfer') {
584
+ const fee = BigInt(terms.amountRaw);
585
+ this.assertFeeCeiling(fee, maxFee, terms.decimals);
586
+ const data = new ethers.Interface(['function transfer(address to, uint256 amount) returns (bool)'])
587
+ .encodeFunctionData('transfer', [terms.recipient, fee]);
588
+ let hash;
589
+ try {
590
+ ({ hash } = await sendAndWait(payer, { to: terms.token, data }, {
591
+ onSent: opts.onFeePaid,
592
+ timeoutMs,
593
+ unconfirmedHint: (h) => `If it confirms, retry with params.feeTxHash = '${h}' so the fee is not paid twice.`,
594
+ }));
595
+ }
596
+ catch (err) {
597
+ if (err instanceof UnconfirmedTransactionError)
598
+ throw this.withFee(err, err.hash, 'UNCONFIRMED');
599
+ throw err;
600
+ }
601
+ return this.deployWithFee(body, hash, pollMs);
602
+ }
603
+ if (!terms.factory)
604
+ throw new ApiError(503, 'This backend charges through AgentFactory but names no factory address.', { terms }, 'DEPLOY_FEE_UNAVAILABLE');
605
+ const factory = new ethers.Interface(['function deployAgent(uint256 usdcAmount)', 'function deployFeeUsdc() view returns (uint256)', 'function usdc() view returns (address)']);
606
+ const reader = payer.provider;
607
+ const [fee] = factory.decodeFunctionResult('deployFeeUsdc', await reader.call({ to: terms.factory, data: factory.encodeFunctionData('deployFeeUsdc') }));
608
+ const [token] = factory.decodeFunctionResult('usdc', await reader.call({ to: terms.factory, data: factory.encodeFunctionData('usdc') }));
609
+ this.assertFeeCeiling(fee, maxFee, 6);
610
+ const nonce = await ensureAllowance(payer, token, terms.factory, fee, { timeoutMs });
611
+ let factoryTx;
612
+ try {
613
+ ({ hash: factoryTx } = await sendAndWait(payer, { to: terms.factory, data: factory.encodeFunctionData('deployAgent', [0]) }, { nonce, timeoutMs }));
614
+ }
615
+ catch (err) {
616
+ if (err instanceof UnconfirmedTransactionError) {
617
+ 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');
618
+ }
619
+ throw err;
620
+ }
621
+ // The backend indexes the factory every 15s: the credit lags the payment.
622
+ try {
623
+ return { ...(await this.postDeploy(body, ['NO_DEPLOY_CREDIT'], 20, pollMs)), feeTxHash: factoryTx };
624
+ }
625
+ catch (err) {
626
+ const e = err;
627
+ 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);
628
+ }
629
+ }
630
+ /** Deploy with a fee transaction already paid: wait for the backend to see it, and make a retry safe. */
631
+ async deployWithFee(body, feeTxHash, pollMs) {
632
+ try {
633
+ const agent = await this.postDeploy({ ...body, feeTxHash }, ['DEPLOY_FEE_NOT_FOUND', 'DEPLOY_FEE_IN_USE', 'DEPLOY_FEE_CHECK_FAILED'], 4, pollMs);
634
+ return { ...agent, feeTxHash };
635
+ }
636
+ catch (err) {
637
+ // This payment already created an agent: a retry after a lost response.
638
+ // Return it when it is the caller's.
639
+ if (err instanceof ApiError && err.code === 'DEPLOY_FEE_ALREADY_USED') {
640
+ const agentId = err.body?.error?.agentId;
641
+ const existing = agentId ? await this.ownAgent(agentId).catch(() => null) : null;
642
+ if (existing)
643
+ return { ...existing, feeTxHash, alreadyDeployed: true };
644
+ }
645
+ throw this.withFee(err, feeTxHash);
646
+ }
647
+ }
648
+ /** `agentId` as a DeployedAgent, when the API key's owner owns it; else null. */
649
+ async ownAgent(agentId) {
650
+ const [agent, who] = await Promise.all([this.getAgent(agentId), this.whoami()]);
651
+ const mine = new Set([who.address, ...(who.addresses ?? [])].map((a) => String(a).toLowerCase()));
652
+ if (!agent?.ownerAddress || !mine.has(agent.ownerAddress.toLowerCase()))
653
+ return null;
654
+ const { id, name, walletAddress, publicKey, inftTokenId, status } = agent;
655
+ return { id, name, walletAddress, publicKey, status, ...(inftTokenId !== undefined ? { inftTokenId } : {}) };
656
+ }
657
+ /**
658
+ * An error after the fee was paid, carrying the payment: `feeTxHash` on the
659
+ * error and in its body, and the message says how to reuse it. The
660
+ * backend's code, status and envelope are kept.
661
+ */
662
+ withFee(err, feeTxHash, fallbackCode) {
663
+ const e = err;
664
+ const spent = err instanceof ApiError && ['DEPLOY_FEE_ALREADY_USED', 'DEPLOY_FEE_NOT_PAID', 'DEPLOY_FEE_REVERTED'].includes(err.code ?? '');
665
+ const message = spent || e.message.includes(feeTxHash)
666
+ ? e.message
667
+ : `${e.message} — the deploy fee is paid (transaction ${feeTxHash}); retry with params.feeTxHash = '${feeTxHash}' so it is not paid twice.`;
668
+ const body = e.body && typeof e.body === 'object' ? { ...e.body, feeTxHash } : { feeTxHash };
669
+ const out = new ApiError(e.status ?? 0, message, body, e.code ?? fallbackCode);
670
+ if (err instanceof ApiError && err.reason)
671
+ out.reason = err.reason;
672
+ out.feeTxHash = feeTxHash;
673
+ return out;
674
+ }
675
+ assertFeeCeiling(fee, maxFee, decimals) {
676
+ if (fee <= maxFee)
677
+ return;
678
+ const fmt = (v) => ethers.formatUnits(v, decimals).replace(/\.0$/, '');
679
+ 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');
680
+ }
681
+ /** POST /agents/deploy, asking again while the backend answers one of `retryCodes`. */
682
+ async postDeploy(body, retryCodes, attempts, pollMs) {
683
+ for (let i = 1;; i++) {
684
+ try {
685
+ return await this.req('POST', '/api/v1/agents/deploy', body);
686
+ }
687
+ catch (err) {
688
+ if (!(err instanceof ApiError) || !retryCodes.includes(err.code ?? '') || i >= attempts)
689
+ throw err;
690
+ await new Promise((r) => setTimeout(r, pollMs));
691
+ }
692
+ }
693
+ }
694
+ /** The configured executor as a signer on `chain`. */
695
+ signerOn(chain, what) {
696
+ if (!this.executor) {
697
+ throw new ApiError(400, `${what} needs a signer: set BlindMarketConfig.executor, or pass one in the options.`, undefined, 'NO_SIGNER');
698
+ }
699
+ const rpc = this.executor.rpcUrls[chain];
700
+ if (!rpc)
701
+ throw new ApiError(400, `${what} happens on ${chain}, but no RPC is configured for it — set rpcUrls.${chain}.`, undefined, 'NO_RPC');
702
+ return new ethers.Wallet(this.executor.privateKey, new ethers.JsonRpcProvider(rpc));
164
703
  }
165
704
  /**
166
705
  * One-shot executor registration in the A2A marketplace.
@@ -257,6 +796,30 @@ export class BlindMarket {
257
796
  }
258
797
  return true;
259
798
  }
799
+ /**
800
+ * Before a spend: throw 409 OWNER_MISMATCH unless `address` is a wallet the
801
+ * backend will credit the spend to. `exact` needs the API key's own address
802
+ * (a task is posted as that wallet); otherwise any wallet linked to it
803
+ * counts, as the deploy fee check does. Unlike assertOwnerKey this fails
804
+ * closed: money never moves on an unchecked wallet.
805
+ */
806
+ async assertSpender(address, what, exact) {
807
+ let who;
808
+ try {
809
+ who = await this.whoami();
810
+ }
811
+ catch (err) {
812
+ 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');
813
+ }
814
+ const allowed = new Set([who.address, ...(exact ? [] : who.addresses ?? [])].filter((a) => typeof a === 'string').map((a) => a.toLowerCase()));
815
+ if (!allowed.has(address.toLowerCase())) {
816
+ throw new ApiError(409, `This API key belongs to ${who.address} but the signer is ${address}. Nothing was sent. ` +
817
+ `${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');
818
+ }
819
+ }
820
+ assertFeePayer(address) {
821
+ return this.assertSpender(address, 'A deploy fee', false);
822
+ }
260
823
  /** List deployed agents, optionally filtered by owner address. */
261
824
  async listAgents(ownerAddress) {
262
825
  const qs = ownerAddress ? `?owner=${ownerAddress}` : '';
@@ -377,13 +940,34 @@ export class BlindMarket {
377
940
  * unsigned `submitEvidence` on the chain the backend names → `finalize()`.
378
941
  * Safe to re-call on a task stranded in 'submitted': INVALID_STATE at submit
379
942
  * and NOT_SUBMITTED_ON_CHAIN at finalize both heal through `rebroadcast()`.
943
+ *
944
+ * The executor key signs only a zero-value `submitEvidence(onChainTaskId,
945
+ * evidenceHash)` on the escrow /health/settlement lists for that chain,
946
+ * where (from /submit) evidenceHash is keccak256 of `JSON.stringify(resultData)`,
947
+ * over an RPC checked to serve that chain, and only its to and data.
948
+ * Anything else throws 409 ESCROW_MISMATCH, TX_MISMATCH, CHAIN_MISMATCH or
949
+ * CHAIN_UNKNOWN (or WRONG_CHAIN for the RPC) with nothing sent.
380
950
  */
381
951
  async deliverResult(taskId, resultData, signerOverride) {
382
952
  const signer = signerOverride ?? this.executor;
383
953
  if (!signer) {
384
954
  throw new ApiError(400, 'deliverResult() needs a signer — pass one, or set BlindMarketConfig.executor. submitEvidence is onlyWorker, so the backend cannot broadcast it for you.');
385
955
  }
386
- const send = async (built) => {
956
+ // Read before /submit, which records the result: a lookup failing here
957
+ // leaves nothing half-done.
958
+ const settlement = await this.getSettlement();
959
+ // The evidence the backend commits for this result (backend/src/routes/a2a.ts).
960
+ const evidence = evidenceHashOf(resultData);
961
+ const what = `Delivering task ${taskId}`;
962
+ /**
963
+ * Sign the submitEvidence the backend built, once it is checked to be a
964
+ * zero-value submitEvidence on the escrow of the chain it names, for the
965
+ * on-chain task it names, and (from /submit) committing THIS result. Only
966
+ * its to and data are signed, on the signer's RPC for that chain, checked
967
+ * to serve it. /rebroadcast re-sends the first stored result, so there the
968
+ * evidence hash is not this call's.
969
+ */
970
+ const send = async (built, fromSubmit) => {
387
971
  if (!built.unsignedSubmitEvidence)
388
972
  return undefined;
389
973
  // Absent `chain` = a backend older than the field, where every task is on 0G.
@@ -394,16 +978,24 @@ export class BlindMarket {
394
978
  if (!rpc) {
395
979
  throw new Error(`task ${taskId} is escrowed on ${chain} but no RPC is configured for it — set rpcUrls.${chain}`);
396
980
  }
397
- // The tx carries chainId, so a wrong RPC fails at ethers instead of
398
- // landing on the wrong network.
981
+ const entry = await this.settlementEntry(chain, what, settlement);
982
+ const onChainId = built.onChainTaskId === undefined ? undefined : taskIdOf(built.onChainTaskId);
983
+ const call = checkEscrowCall(built.unsignedSubmitEvidence, {
984
+ escrow: entry.escrowAddress,
985
+ fn: 'submitEvidence',
986
+ args: (a) => (built.onChainTaskId === undefined || a[0] === onChainId)
987
+ && (!fromSubmit || String(a[1]).toLowerCase() === evidence),
988
+ chainId: entry.chainId,
989
+ }, what);
399
990
  const wallet = new ethers.Wallet(signer.privateKey, new ethers.JsonRpcProvider(rpc));
400
- const tx = await wallet.sendTransaction(built.unsignedSubmitEvidence);
991
+ await assertSignerChain(wallet, entry.chainId, what);
992
+ const tx = await wallet.sendTransaction(call);
401
993
  await tx.wait();
402
994
  return tx.hash;
403
995
  };
404
996
  const healStranded = async () => {
405
997
  try {
406
- return await send(await this.rebroadcast(taskId));
998
+ return await send(await this.rebroadcast(taskId), false);
407
999
  }
408
1000
  catch (err) {
409
1001
  // Evidence is already on-chain — nothing to broadcast, go finalize.
@@ -414,7 +1006,7 @@ export class BlindMarket {
414
1006
  };
415
1007
  let submitTxHash;
416
1008
  try {
417
- submitTxHash = await send(await this.submitResult(taskId, resultData));
1009
+ submitTxHash = await send(await this.submitResult(taskId, resultData), true);
418
1010
  }
419
1011
  catch (err) {
420
1012
  if (!(err instanceof ApiError && err.code === 'INVALID_STATE'))
@@ -431,6 +1023,16 @@ export class BlindMarket {
431
1023
  return { ...(await this.finalize(taskId)), submitTxHash };
432
1024
  }
433
1025
  }
1026
+ /**
1027
+ * Approve or reject the delivered result of a task you posted with
1028
+ * `verificationMode: 'manual'` (`POST /api/v1/a2a/tasks/:hash/verify`).
1029
+ * Approving settles the escrow to the worker (90%); rejecting fails the
1030
+ * round, and the worker may resubmit before the deadline. Only the poster
1031
+ * can review, and only once the task is `submitted`.
1032
+ */
1033
+ async reviewResult(taskHash, review) {
1034
+ return this.req('POST', `/api/v1/a2a/tasks/${encodeURIComponent(taskHash)}/verify`, review);
1035
+ }
434
1036
  /** Get tasks posted by the authenticated user. */
435
1037
  async getPostedTasks() {
436
1038
  return this.req('GET', '/api/v1/a2a/tasks/posted');
@@ -470,7 +1072,11 @@ export class BlindMarket {
470
1072
  return this.req('GET', `/api/v1/reputation/leaderboard?limit=${limit}`);
471
1073
  }
472
1074
  // ── Storage ─────────────────────────────────────────────────────────────
473
- /** Upload an encrypted blob to 0G Storage. */
1075
+ /**
1076
+ * Upload a blob to 0G Storage. `data` is the bytes as **base64**: the
1077
+ * backend base64-decodes it. (The type once said Hex; a hex string sent
1078
+ * here uploads the wrong bytes.)
1079
+ */
474
1080
  async uploadBlob(data) {
475
1081
  return this.req('POST', '/api/v1/storage/upload', { data });
476
1082
  }
@@ -481,7 +1087,8 @@ export class BlindMarket {
481
1087
  * which returns `{ rootHash, blob }`, not `{ data }`.
482
1088
  */
483
1089
  async downloadBlob(rootHash) {
484
- return this.req('GET', `/api/v1/storage/${rootHash}`);
1090
+ // Encoded so a rootHash can never step out of /storage/.
1091
+ return this.req('GET', `/api/v1/storage/${encodeURIComponent(rootHash)}`);
485
1092
  }
486
1093
  // ── Messages ─────────────────────────────────────────────────────────────
487
1094
  /** Send a message to another user or agent. */