@blindmarket/sdk 0.8.0 → 0.9.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,16 +1,125 @@
1
1
  import { ethers } from 'ethers';
2
2
  import { ApiError } from './apiError.js';
3
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
4
  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');
5
+ import { isPinnedSettlement, SETTLEMENT_PINS } from './settlementPins.js';
6
+ import { normalizePost, checkRowFields, sealBrief, createTaskBody, indexParamsFor, commitsVerifier, } from './posting.js';
7
+ // ── Posting many tasks: internals ───────────────────────────────────────────
8
+ /** Rows per postTasks() call: past this, split the list (each row is sealed in memory first). */
9
+ const MAX_POST_TASKS_ROWS = 1000;
10
+ /** Rows per createTasks transaction unless chunkSize says otherwise. */
11
+ const DEFAULT_CHUNK_SIZE = 20;
12
+ /** POST /storage/upload-batch and /a2a/tasks/index-batch take at most this many items. */
13
+ const MAX_BATCH_REQUEST = 50;
14
+ const MAX_RETRY_DELAY_MS = 30_000;
15
+ /** The category the backend builds every task with (backend/src/routes/tasks.ts): bound in the calldata check like the rest. */
16
+ const TASK_CATEGORY = 'general';
17
+ /** A createTasks gas limit is its estimate plus a fifth. */
18
+ const BATCH_GAS_HEADROOM_PCT = 120n;
19
+ /**
20
+ * Briefs per /storage/upload-batch request. The backend stores briefs on 0G
21
+ * one at a time (20–40 s each) and answers within ~85 s, and production sits
22
+ * behind a ~100 s edge timeout (Cloudflare's 524), so a request carries two
23
+ * at most. The web app does the same (frontend/src/lib/postTaskFlow.ts).
24
+ */
25
+ const UPLOAD_GROUP = 2;
26
+ /** How long one upload request may take before it counts as timed out. */
27
+ const UPLOAD_TIMEOUT_MS = 95_000;
28
+ function retryPolicy(retry) {
29
+ const attempts = retry?.attempts ?? 5;
30
+ const baseDelayMs = retry?.baseDelayMs ?? 2_000;
31
+ return {
32
+ attempts: Number.isInteger(attempts) && attempts >= 1 ? attempts : 5,
33
+ baseDelayMs: Number.isFinite(baseDelayMs) && baseDelayMs >= 0 ? baseDelayMs : 2_000,
34
+ };
35
+ }
36
+ /** A rate limit, a server error, a network failure, or a body that was not JSON (a proxy's error page): worth asking again. */
37
+ function isTransient(err) {
38
+ if (err instanceof ApiError)
39
+ return err.status === 429 || err.status >= 500 || err.code === 'RATE_LIMIT' || err.code === 'TIMEOUT';
40
+ return err instanceof TypeError || err instanceof SyntaxError;
41
+ }
42
+ /**
43
+ * Storage busy or unreachable, as opposed to a brief the backend refused or
44
+ * an answer that does not add up: a dropped connection (fetch's TypeError),
45
+ * this client's own timeout, or a gateway giving up (502, 503, 504,
46
+ * Cloudflare's 524): the web app's isTransientUploadError. A rate limit
47
+ * (429) counts too: it asks to come back later, which the backoff does.
48
+ */
49
+ function isTransientUpload(err) {
50
+ if (err instanceof TypeError)
51
+ return true;
52
+ if (!(err instanceof ApiError))
53
+ return false;
54
+ return err.code === 'TIMEOUT' || err.code === 'RATE_LIMIT' || [429, 502, 503, 504, 524].includes(err.status);
55
+ }
56
+ /**
57
+ * Failures before a row is funded that stop the whole run rather than that
58
+ * row: a backend that built the wrong transaction or moved chain, an auth
59
+ * failure, or one that stayed unreachable through every retry. Anything else
60
+ * (a brief the backend refuses, say) fails the row alone.
61
+ */
62
+ const HALTING_CODES = new Set([
63
+ 'ESCROW_MISMATCH', 'TX_MISMATCH', 'CHAIN_MISMATCH', 'POSTING_CHAIN_CHANGED', 'CHAIN_UNKNOWN',
64
+ 'SETTLEMENT_NOT_POSTABLE', 'WRONG_CHAIN', 'OWNER_MISMATCH', 'UPLOAD_MISMATCH',
65
+ ]);
66
+ function haltsBeforeFunding(err) {
67
+ if (err instanceof ApiError && (HALTING_CODES.has(err.code ?? '') || err.status === 401 || err.status === 403))
68
+ return true;
69
+ // The escrow approve reverted or never confirmed: no row can be funded.
70
+ if (!(err instanceof ApiError))
71
+ return true;
72
+ return isTransient(err);
73
+ }
74
+ function errorInfo(err) {
75
+ const e = err;
76
+ const code = err instanceof ApiError || err instanceof UnconfirmedTransactionError
77
+ ? err.code
78
+ : typeof e?.code === 'string' ? e.code : undefined;
79
+ return { ...(code ? { code } : {}), message: typeof e?.message === 'string' ? e.message : String(err) };
13
80
  }
81
+ function haltAt(index, err, code) {
82
+ const info = errorInfo(err);
83
+ const c = code ?? info.code;
84
+ return { index, ...(c ? { code: c } : {}), message: info.message };
85
+ }
86
+ function rowError(index, err) {
87
+ const info = errorInfo(err);
88
+ return { index, code: info.code ?? 'INVALID_ROW', message: info.message };
89
+ }
90
+ function invalidRows(errors, total) {
91
+ errors.sort((a, b) => a.index - b.index);
92
+ const shown = errors.slice(0, 3).map((e) => `rows[${e.index}]: ${e.message.replace(/\s*Nothing was sent\.?$/, '')}`).join('; ');
93
+ return new ApiError(400, `${errors.length} of ${total} rows cannot be posted as they are (${shown}${errors.length > 3 ? '; …' : ''}). Nothing was sent: fix them, or leave them out, and post again.`, { errors }, 'INVALID_ROWS');
94
+ }
95
+ function failedRow(index, err) {
96
+ return { index, status: 'failed', error: errorInfo(err) };
97
+ }
98
+ function unlistedRow(index, txHash, batch, indexParams, aesKey, error) {
99
+ return {
100
+ index, status: 'unlisted', taskHash: indexParams.taskHash, txHash, batch,
101
+ indexParams: { ...indexParams, txHash }, ...(aesKey ? { aesKey } : {}), error,
102
+ };
103
+ }
104
+ /**
105
+ * The rows an all-or-nothing POST /tasks/batch refused, by position in the
106
+ * request, when its 400 names them ({ errors: [{ index, code, message }] });
107
+ * null when it does not, so the whole call fails as it came back.
108
+ */
109
+ function refusedRows(err, count) {
110
+ if (!(err instanceof ApiError) || err.status !== 400)
111
+ return null;
112
+ const body = err.body;
113
+ const list = body?.error?.errors ?? body?.errors ?? body?.error?.details?.errors;
114
+ if (!Array.isArray(list))
115
+ return null;
116
+ const rows = list
117
+ .filter((e) => !!e && typeof e === 'object' && Number.isInteger(e.index)
118
+ && e.index >= 0 && e.index < count)
119
+ .map((e) => ({ index: e.index, code: typeof e.code === 'string' ? e.code : 'INVALID_ROW', message: typeof e.message === 'string' ? e.message : 'refused by the backend' }));
120
+ return rows.length > 0 ? rows : null;
121
+ }
122
+ const capsKey = (caps) => [...caps].sort().join(',');
14
123
  // ── Main client ─────────────────────────────────────────────────────────────
15
124
  /**
16
125
  * BlindMarket REST API client.
@@ -36,10 +145,12 @@ export class BlindMarket {
36
145
  apiBase;
37
146
  apiKey;
38
147
  executor;
148
+ trustedEscrows;
39
149
  constructor(config) {
40
150
  this.apiBase = config.apiBase ?? 'https://api.blindmarket.xyz';
41
151
  this.apiKey = config.apiKey;
42
152
  this.executor = config.executor;
153
+ this.trustedEscrows = config.trustedEscrows ?? [];
43
154
  }
44
155
  /** True when an executor signer was configured (see BlindMarketConfig.executor). */
45
156
  get canSign() {
@@ -72,16 +183,40 @@ export class BlindMarket {
72
183
  * generateText({ model, tools: tools(bb).vercel });
73
184
  * ```
74
185
  */
75
- async req(method, path, body) {
76
- const res = await fetch(`${this.apiBase}${path}`, {
77
- method,
78
- headers: {
79
- 'Content-Type': 'application/json',
80
- Authorization: `Bearer ${this.apiKey}`,
81
- },
82
- body: body ? JSON.stringify(body) : undefined,
83
- });
84
- const json = await res.json();
186
+ /**
187
+ * `timeoutMs` gives up on a request that has not answered (ApiError
188
+ * TIMEOUT). `strictBody` turns a reply that is not JSON (a gateway's error
189
+ * page, such as Cloudflare's 524) into an ApiError carrying its HTTP status,
190
+ * instead of the parser's SyntaxError.
191
+ */
192
+ async req(method, path, body, opts = {}) {
193
+ let res;
194
+ try {
195
+ res = await fetch(`${this.apiBase}${path}`, {
196
+ method,
197
+ headers: {
198
+ 'Content-Type': 'application/json',
199
+ Authorization: `Bearer ${this.apiKey}`,
200
+ },
201
+ body: body ? JSON.stringify(body) : undefined,
202
+ ...(opts.timeoutMs ? { signal: AbortSignal.timeout(opts.timeoutMs) } : {}),
203
+ });
204
+ }
205
+ catch (err) {
206
+ if (opts.timeoutMs && err?.name === 'TimeoutError') {
207
+ throw new ApiError(0, `${path} did not answer within ${Math.round(opts.timeoutMs / 1000)} s.`, undefined, 'TIMEOUT');
208
+ }
209
+ throw err;
210
+ }
211
+ let json;
212
+ try {
213
+ json = await res.json();
214
+ }
215
+ catch (err) {
216
+ if (opts.strictBody)
217
+ throw new ApiError(res.status, `${path} answered HTTP ${res.status} with a body that is not JSON.`, undefined, `HTTP_${res.status}`);
218
+ throw err;
219
+ }
85
220
  if (!json.success) {
86
221
  const err = new ApiError(res.status, json.error?.message ?? `HTTP ${res.status}`, json, json.error?.code);
87
222
  if (typeof json.error?.reason === 'string')
@@ -197,139 +332,27 @@ export class BlindMarket {
197
332
  * );
198
333
  */
199
334
  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
- }
335
+ const post = normalizePost(params, opts.maxAmountRaw);
336
+ const ctx = await this.postingContext(opts.signer);
337
+ await this.assertCovers(ctx, post.amount);
236
338
  // 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}`);
339
+ const executors = post.privacy === 'public' ? [] : await this.postingExecutors(ctx.postingChain, post.requiredCapabilities);
340
+ const sealed = await sealBrief(post, executors, ctx.postingChain);
341
+ const { taskHash } = sealed;
342
+ const { rootHash } = await this.uploadBlob(ethers.encodeBase64(sealed.blob));
343
+ const built = await this.createTask(createTaskBody(post, sealed, ctx.token, rootHash));
344
+ const createCall = this.checkedCreateCall(ctx, built, post, taskHash);
310
345
  const timeoutMs = opts.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS;
311
346
  // 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
- };
347
+ const nonce = ctx.isNative ? undefined : await ensureAllowance(ctx.signer, ctx.token, ctx.escrow, post.amount, { timeoutMs });
348
+ const indexParams = indexParamsFor(post, sealed, rootHash);
326
349
  let txHash;
327
350
  try {
328
- ({ hash: txHash } = await sendAndWait(signer, createCall, {
329
- value: isNative ? amount : undefined,
351
+ ({ hash: txHash } = await sendAndWait(ctx.signer, createCall, {
352
+ value: ctx.isNative ? post.amount : undefined,
330
353
  nonce,
331
354
  timeoutMs,
332
- onSent: (hash) => opts.onFunded?.({ txHash: hash, taskHash, indexParams: { ...indexParams, txHash: hash } }),
355
+ onSent: (hash, sentNonce, raw) => opts.onFunded?.({ txHash: hash, nonce: sentNonce, ...(raw ? { raw } : {}), taskHash, indexParams: { ...indexParams, txHash: hash } }),
333
356
  unconfirmedHint: (hash) => `If it confirms, call indexTask() with txHash '${hash}' to list the task; do not fund it again.`,
334
357
  }));
335
358
  }
@@ -352,18 +375,553 @@ export class BlindMarket {
352
375
  out.txHash = txHash;
353
376
  throw out;
354
377
  }
378
+ return this.postedTask(ctx, post, sealed, rootHash, txHash, indexed.onChainTaskId);
379
+ }
380
+ /**
381
+ * Post many tasks, from the API key's own wallet: postTask() for a list, in
382
+ * as few transactions as the posting chain's escrow allows.
383
+ *
384
+ * Every row is checked and its brief sealed before anything is uploaded or
385
+ * sent: a row the escrow or the backend would refuse throws 400
386
+ * INVALID_ROWS listing every such row (`err.body.errors`), with nothing
387
+ * sent. The wallet must hold the total, and the escrow is approved for it
388
+ * once, just before the first funding transaction.
389
+ *
390
+ * On an escrow with createTasks (SettlementChainInfo.batchCreate) up to
391
+ * `chunkSize` rows share one transaction and one listing call; otherwise
392
+ * each row is its own createTask, as postTask() sends it. The same checks
393
+ * guard every transaction: the backend's build must be exactly these tasks,
394
+ * for this escrow and chain, before a key signs it.
395
+ *
396
+ * A row the backend refuses before funding fails alone and the run goes on.
397
+ * A funding transaction that reverts or cannot be confirmed, a listing that
398
+ * fails, or a backend that builds the wrong transaction or stays
399
+ * unreachable stops the run there (`result.stopped`), so no further escrow
400
+ * is funded behind a problem. Nothing is ever funded twice: a funded row
401
+ * that is not listed comes back 'unlisted' with its `indexParams`.
402
+ *
403
+ * @example
404
+ * const res = await bb.postTasks(rows, {
405
+ * onFunded: ({ taskHash, indexParams }) => save(taskHash, indexParams),
406
+ * onProgress: ({ done, total }) => console.log(`${done}/${total}`),
407
+ * });
408
+ */
409
+ async postTasks(rows, opts = {}) {
410
+ if (!Array.isArray(rows) || rows.length === 0) {
411
+ throw new ApiError(400, 'postTasks() needs at least one row. Nothing was sent.', undefined, 'NO_ROWS');
412
+ }
413
+ if (rows.length > MAX_POST_TASKS_ROWS) {
414
+ throw new ApiError(400, `postTasks() takes at most ${MAX_POST_TASKS_ROWS} rows per call, not ${rows.length}: split the list. Nothing was sent.`, undefined, 'TOO_MANY_ROWS');
415
+ }
416
+ if (opts.chunkSize !== undefined && (!Number.isInteger(opts.chunkSize) || opts.chunkSize < 1)) {
417
+ throw new ApiError(400, `chunkSize must be a whole number of rows from 1, not ${opts.chunkSize}. Nothing was sent.`, undefined, 'INVALID_CHUNK_SIZE');
418
+ }
419
+ // 1. Each row's own fields, before any lookup.
420
+ const errors = [];
421
+ const checked = rows.map((row, index) => {
422
+ try {
423
+ checkRowFields(row);
424
+ return normalizePost(row);
425
+ }
426
+ catch (err) {
427
+ errors.push(rowError(index, err));
428
+ return undefined;
429
+ }
430
+ });
431
+ if (errors.length > 0)
432
+ throw invalidRows(errors, rows.length);
433
+ const posts = checked;
434
+ const total = posts.reduce((sum, p) => sum + p.amount, 0n);
435
+ if (opts.maxTotalRaw !== undefined && total > BigInt(opts.maxTotalRaw)) {
436
+ throw new ApiError(402, `The ${rows.length} escrows total ${total}, above your limit of ${opts.maxTotalRaw}. Nothing was sent.`, undefined, 'AMOUNT_ABOVE_MAX');
437
+ }
438
+ // 2. Where, and from which wallet: postTask()'s checks, for the total.
439
+ const retry = retryPolicy(opts.retry);
440
+ const ctx = await this.postingContext(opts.signer);
441
+ await this.assertCovers(ctx, total);
442
+ // 3. Every brief sealed, still before anything is sent. Executors are
443
+ // asked for once per capability list.
444
+ const executors = new Map();
445
+ for (const post of posts) {
446
+ if (post.privacy !== 'private')
447
+ continue;
448
+ const key = capsKey(post.requiredCapabilities);
449
+ if (!executors.has(key))
450
+ executors.set(key, await this.retrying(() => this.postingExecutors(ctx.postingChain, post.requiredCapabilities), retry));
451
+ }
452
+ const sealed = [];
453
+ for (let i = 0; i < posts.length; i++) {
454
+ try {
455
+ const post = posts[i];
456
+ sealed[i] = await sealBrief(post, post.privacy === 'private' ? executors.get(capsKey(post.requiredCapabilities)) : [], ctx.postingChain);
457
+ }
458
+ catch (err) {
459
+ errors.push(rowError(i, err));
460
+ }
461
+ }
462
+ // A public brief's task hash is the brief's hash, and the market lists a hash once.
463
+ const firstWithHash = new Map();
464
+ sealed.forEach((s, i) => {
465
+ if (!s)
466
+ return;
467
+ const hash = s.taskHash.toLowerCase();
468
+ const first = firstWithHash.get(hash);
469
+ if (first === undefined)
470
+ firstWithHash.set(hash, i);
471
+ else
472
+ errors.push({ index: i, code: 'DUPLICATE_BRIEF', message: `rows[${i}] is the same public brief as rows[${first}], and the market lists a brief once. Nothing was sent.` });
473
+ });
474
+ if (errors.length > 0)
475
+ throw invalidRows(errors, rows.length);
476
+ // 4. Fund and list, in as few transactions as the escrow allows.
477
+ const batch = ctx.entry.batchCreate;
478
+ const batchable = !ctx.isNative && batch?.supported === true && Number.isInteger(batch.maxBatch) && batch.maxBatch > 1;
479
+ const size = batchable ? Math.max(1, Math.min(opts.chunkSize ?? DEFAULT_CHUNK_SIZE, batch.maxBatch, MAX_BATCH_REQUEST)) : 1;
480
+ let mode = size > 1 ? 'batch' : 'single';
481
+ let units = [];
482
+ for (let i = 0; i < posts.length; i += size)
483
+ units.push(posts.slice(i, i + size).map((_, j) => i + j));
484
+ const results = new Array(rows.length);
485
+ const run = {
486
+ ctx, posts, sealed, opts, retry,
487
+ timeoutMs: opts.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS,
488
+ pending: new Set(posts.map((_, i) => i)),
489
+ approved: ctx.isNative,
490
+ settle: async (result) => {
491
+ results[result.index] = result;
492
+ run.pending.delete(result.index);
493
+ try {
494
+ await opts.onProgress?.({ done: rows.length - run.pending.size, total: rows.length, result });
495
+ }
496
+ catch { /* a progress callback never stops a run that is moving money */ }
497
+ },
498
+ };
499
+ let stopped;
500
+ for (let u = 0; u < units.length; u++) {
501
+ const unit = units[u];
502
+ if (!stopped && opts.signal?.aborted) {
503
+ stopped = { index: unit[0], code: 'ABORTED', message: 'The run was aborted before this row started.' };
504
+ }
505
+ if (stopped) {
506
+ const reason = stopped.code === 'ABORTED' ? 'aborted' : `stopped at rows[${stopped.index}]: ${stopped.message}`;
507
+ for (const i of unit)
508
+ await run.settle({ index: i, status: 'skipped', reason });
509
+ continue;
510
+ }
511
+ const outcome = unit.length === 1 ? await this.postRow(run, unit[0]) : await this.postChunk(run, unit);
512
+ if (outcome.fallback && outcome.fallback.length > 0) {
513
+ // The escrow refused createTasks after all: the rest go one by one.
514
+ mode = 'single';
515
+ units = [...units.slice(0, u + 1), ...outcome.fallback.map((i) => [i]), ...units.slice(u + 1).flat().map((i) => [i])];
516
+ }
517
+ if (outcome.halt)
518
+ stopped = outcome.halt;
519
+ }
520
+ const count = (status) => results.filter((r) => r.status === status).length;
521
+ return {
522
+ chain: ctx.postingChain,
523
+ chainId: ctx.entry.chainId,
524
+ mode,
525
+ results,
526
+ posted: count('posted'),
527
+ unlisted: count('unlisted'),
528
+ failed: count('failed'),
529
+ skipped: count('skipped'),
530
+ ...(stopped ? { stopped } : {}),
531
+ };
532
+ }
533
+ /**
534
+ * Build and list several posts at once (POST /api/v1/tasks/batch): one
535
+ * unsigned createTasks transaction for the posting chain's escrow. Only an
536
+ * escrow with createTasks builds it (409 BATCH_UNSUPPORTED otherwise);
537
+ * postTasks() calls it, and checks the transaction before signing.
538
+ */
539
+ async createTasks(params) {
540
+ return this.req('POST', '/api/v1/tasks/batch', params);
541
+ }
542
+ /**
543
+ * List every task one funding transaction created
544
+ * (POST /api/v1/a2a/tasks/index-batch). It works for a transaction that
545
+ * funded one task too. Each task comes back listed, or with its own error;
546
+ * a task the receipt does not hold is NOT_IN_RECEIPT. postTasks() calls it;
547
+ * call it yourself to finish rows it returned 'unlisted' with `batch` true.
548
+ */
549
+ async indexTasks(params) {
550
+ return this.req('POST', '/api/v1/a2a/tasks/index-batch', params);
551
+ }
552
+ /**
553
+ * Upload several blobs to 0G Storage in one call
554
+ * (POST /api/v1/storage/upload-batch). Each is base64, as uploadBlob()
555
+ * takes it; the results come back in the same order. All or nothing.
556
+ */
557
+ async uploadBlobs(data) {
558
+ const { results } = await this.req('POST', '/api/v1/storage/upload-batch', { items: data.map((d) => ({ data: d })) });
559
+ return results;
560
+ }
561
+ /** The posting chain, its escrow and token, and a signer checked to be the API key's own wallet on that chain. */
562
+ async postingContext(signerOption) {
563
+ const { postingChain, chains } = await this.getSettlement();
564
+ const entry = chains.find((c) => c.chain === postingChain);
565
+ if (!postingChain || !entry || !entry.escrowAddress || !entry.token.address) {
566
+ 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');
567
+ }
568
+ // The escrow approved and funded, and its token, must be a known
569
+ // deployment (or one the caller trusts), not whatever the backend names.
570
+ if (!isPinnedSettlement(entry.chainId, entry.escrowAddress, entry.token.address, this.trustedEscrows)) {
571
+ const known = [...SETTLEMENT_PINS, ...this.trustedEscrows].filter((p) => p.chainId === entry.chainId);
572
+ throw new ApiError(409, `The backend names escrow ${entry.escrowAddress} and token ${entry.token.address} on ${postingChain} (chain ${entry.chainId}), which ${known.length ? `is not the known deployment (escrow ${known.map((p) => p.escrow).join(' or ')})` : 'has no known deployment'}. Nothing was approved or sent. For a custom or local deployment, list it in BlindMarketConfig.trustedEscrows.`, undefined, 'ESCROW_NOT_PINNED');
573
+ }
574
+ const signer = signerOption ?? this.signerOn(postingChain, 'Funding the escrow');
575
+ const poster = await signer.getAddress();
576
+ await this.assertSpender(poster, 'A task', true);
577
+ await assertSignerChain(signer, entry.chainId, `Funding the escrow on ${postingChain}`);
578
+ return {
579
+ postingChain,
580
+ entry: entry,
581
+ escrow: entry.escrowAddress,
582
+ token: entry.token.address,
583
+ isNative: entry.token.kind === 'native',
584
+ signer,
585
+ poster,
586
+ };
587
+ }
588
+ /** Throws 402 INSUFFICIENT_BALANCE, with nothing sent, when the wallet holds less than `amount` of an ERC-20 settlement token. */
589
+ async assertCovers(ctx, amount) {
590
+ if (ctx.isNative)
591
+ return;
592
+ const balance = await tokenBalance(ctx.signer, ctx.token, ctx.poster);
593
+ if (balance < amount) {
594
+ const fmt = (v) => ethers.formatUnits(v, ctx.entry.token.decimals);
595
+ throw new ApiError(402, `${ctx.poster} holds ${fmt(balance)} ${ctx.entry.token.symbol} on ${ctx.postingChain}; the escrow needs ${fmt(amount)}. Nothing was sent.`, undefined, 'INSUFFICIENT_BALANCE');
596
+ }
597
+ }
598
+ /** The executors on the posting chain a private brief can be wrapped to. */
599
+ async postingExecutors(postingChain, capabilities) {
600
+ const qs = new URLSearchParams({ capabilities: capabilities.join(','), chain: postingChain });
601
+ const { executors } = await this.req('GET', `/api/v1/a2a/executors?${qs}`);
602
+ return executors;
603
+ }
604
+ /**
605
+ * The backend's createTask, checked to be exactly this post before a key
606
+ * signs it: the chain and escrow checked above, the task hash, the token,
607
+ * the amount, the zone and the duration (a verifier commits through
608
+ * createTaskWithVerifier). Only its to and data are signed; the value is
609
+ * the amount computed here.
610
+ */
611
+ checkedCreateCall(ctx, built, post, taskHash) {
612
+ // A backend whose posting chain moved in between would otherwise have it signed blind.
613
+ if ((built.chain !== undefined && built.chain !== ctx.postingChain) || (built.chainId !== undefined && Number(built.chainId) !== ctx.entry.chainId)) {
614
+ throw new ApiError(409, `The backend built this task for ${built.chain} (chain ${built.chainId}), not ${ctx.postingChain}: its posting chain changed. Nothing was sent; try again.`, undefined, 'POSTING_CHAIN_CHANGED');
615
+ }
616
+ const withVerifier = commitsVerifier(post);
617
+ return checkEscrowCall(built.unsignedTx, {
618
+ escrow: ctx.escrow,
619
+ fn: withVerifier ? 'createTaskWithVerifier' : 'createTask',
620
+ args: (a) => String(a[0]).toLowerCase() === taskHash.toLowerCase()
621
+ && String(a[1]).toLowerCase() === ctx.token.toLowerCase()
622
+ && a[2] === post.amount
623
+ && a[3] === TASK_CATEGORY
624
+ && a[4] === post.locationZone
625
+ && a[5] === BigInt(post.duration)
626
+ && (!withVerifier || String(a[6]).toLowerCase() === post.verifierAddress.toLowerCase()),
627
+ value: ctx.isNative ? post.amount : 0n,
628
+ chainId: ctx.entry.chainId,
629
+ }, `Funding the escrow on ${ctx.postingChain}`);
630
+ }
631
+ /** The backend's createTasks, checked to be exactly these posts, in this order, for this token, escrow and chain. */
632
+ checkedCreateTasksCall(ctx, built, rows) {
633
+ if ((built.chain !== undefined && built.chain !== ctx.postingChain) || (built.chainId !== undefined && Number(built.chainId) !== ctx.entry.chainId)) {
634
+ throw new ApiError(409, `The backend built these tasks for ${built.chain} (chain ${built.chainId}), not ${ctx.postingChain}: its posting chain changed. Nothing was sent; try again.`, undefined, 'POSTING_CHAIN_CHANGED');
635
+ }
636
+ return checkEscrowCall(built.unsignedTx, {
637
+ escrow: ctx.escrow,
638
+ fn: 'createTasks',
639
+ args: (a) => {
640
+ if (String(a[0]).toLowerCase() !== ctx.token.toLowerCase())
641
+ return false;
642
+ const tasks = a[1];
643
+ if (tasks.length !== rows.length)
644
+ return false;
645
+ return rows.every(({ post, taskHash }, j) => {
646
+ const t = tasks[j];
647
+ const verifier = commitsVerifier(post) ? post.verifierAddress.toLowerCase() : ethers.ZeroAddress;
648
+ return String(t[0]).toLowerCase() === taskHash.toLowerCase()
649
+ && t[1] === post.amount
650
+ && t[2] === TASK_CATEGORY
651
+ && t[3] === post.locationZone
652
+ && t[4] === BigInt(post.duration)
653
+ && String(t[5]).toLowerCase() === verifier;
654
+ });
655
+ },
656
+ value: 0n,
657
+ chainId: ctx.entry.chainId,
658
+ }, `Funding ${rows.length} escrows on ${ctx.postingChain}`);
659
+ }
660
+ postedTask(ctx, post, sealed, rootHash, txHash, onChainTaskId) {
355
661
  return {
356
- taskHash,
357
- ...(indexed.onChainTaskId !== undefined ? { taskId: String(indexed.onChainTaskId) } : {}),
662
+ taskHash: sealed.taskHash,
663
+ ...(onChainTaskId !== undefined ? { taskId: String(onChainTaskId) } : {}),
358
664
  txHash,
359
- chain: postingChain,
360
- chainId: entry.chainId,
665
+ chain: ctx.postingChain,
666
+ chainId: ctx.entry.chainId,
361
667
  rootHash,
362
- privacy,
363
- wrappedTo: wrappedKeys ? Object.keys(wrappedKeys).length : 0,
364
- ...(aesKey ? { aesKey } : {}),
668
+ privacy: post.privacy,
669
+ wrappedTo: sealed.wrappedKeys ? Object.keys(sealed.wrappedKeys).length : 0,
670
+ ...(sealed.aesKey ? { aesKey: sealed.aesKey } : {}),
365
671
  };
366
672
  }
673
+ /**
674
+ * The escrow's ERC-20 allowance for every row still to fund, approved once,
675
+ * right before the first funding transaction (after that transaction's
676
+ * build has been checked, as postTask() approves).
677
+ */
678
+ async approveRemaining(run) {
679
+ if (run.approved)
680
+ return;
681
+ let remaining = 0n;
682
+ for (const i of run.pending)
683
+ remaining += run.posts[i].amount;
684
+ const nonce = await ensureAllowance(run.ctx.signer, run.ctx.token, run.ctx.escrow, remaining, { timeoutMs: run.timeoutMs });
685
+ run.approved = true;
686
+ if (nonce !== undefined)
687
+ run.nonce = nonce;
688
+ }
689
+ /** Every row a transaction funds, told its hash (again with a replacement's hash if the wallet re-priced it). */
690
+ async notifyFunded(run, rows, hash, nonce, batch, raw) {
691
+ for (const { index, indexParams } of rows) {
692
+ try {
693
+ await run.opts.onFunded?.({ index, txHash: hash, nonce, ...(raw ? { raw } : {}), taskHash: indexParams.taskHash, batch, indexParams: { ...indexParams, txHash: hash } });
694
+ }
695
+ catch { /* the transaction is out; one row's callback must not keep the others from hearing it */ }
696
+ }
697
+ }
698
+ /** One row as its own createTask, the way postTask() posts it. */
699
+ async postRow(run, i) {
700
+ const { ctx, retry } = run;
701
+ const post = run.posts[i];
702
+ const sealed = run.sealed[i];
703
+ let call;
704
+ let rootHash;
705
+ try {
706
+ [rootHash] = await this.uploadBriefs([ethers.encodeBase64(sealed.blob)], retry, false);
707
+ const built = await this.retrying(() => this.createTask(createTaskBody(post, sealed, ctx.token, rootHash)), retry);
708
+ call = this.checkedCreateCall(ctx, built, post, sealed.taskHash);
709
+ await this.approveRemaining(run);
710
+ }
711
+ catch (err) {
712
+ await run.settle(failedRow(i, err));
713
+ return haltsBeforeFunding(err) ? { halt: haltAt(i, err) } : {};
714
+ }
715
+ const indexParams = indexParamsFor(post, sealed, rootHash);
716
+ let txHash;
717
+ try {
718
+ const sent = await sendAndWait(ctx.signer, call, {
719
+ value: ctx.isNative ? post.amount : undefined,
720
+ nonce: run.nonce,
721
+ timeoutMs: run.timeoutMs,
722
+ onSent: (hash, nonce, raw) => this.notifyFunded(run, [{ index: i, indexParams }], hash, nonce, false, raw),
723
+ unconfirmedHint: (hash) => `If it confirms, call indexTask() with txHash '${hash}' to list the task; do not fund it again.`,
724
+ });
725
+ run.nonce = sent.nonce + 1;
726
+ txHash = sent.hash;
727
+ }
728
+ catch (err) {
729
+ run.nonce = undefined;
730
+ if (err instanceof UnconfirmedTransactionError) {
731
+ await run.settle(unlistedRow(i, err.hash, false, indexParams, sealed.aesKey, { code: 'UNCONFIRMED', message: err.message }));
732
+ return { halt: haltAt(i, err, 'UNCONFIRMED') };
733
+ }
734
+ await run.settle(failedRow(i, err));
735
+ return { halt: haltAt(i, err) };
736
+ }
737
+ indexParams.txHash = txHash;
738
+ try {
739
+ const indexed = await this.retrying(() => this.indexTask(indexParams), retry, { receiptLag: true });
740
+ await run.settle({ index: i, status: 'posted', task: this.postedTask(ctx, post, sealed, rootHash, txHash, indexed.onChainTaskId) });
741
+ return {};
742
+ }
743
+ catch (err) {
744
+ await run.settle(unlistedRow(i, txHash, false, indexParams, sealed.aesKey, errorInfo(err)));
745
+ return { halt: haltAt(i, err) };
746
+ }
747
+ }
748
+ /** Several rows in one createTasks transaction, listed with one call. */
749
+ async postChunk(run, unit) {
750
+ const { ctx, retry } = run;
751
+ let live = [...unit];
752
+ const rootHashes = new Map();
753
+ let call;
754
+ let gasLimit;
755
+ try {
756
+ // Every brief of the chunk is stored before anything of it is built or funded.
757
+ const roots = await this.uploadBriefs(live.map((i) => ethers.encodeBase64(run.sealed[i].blob)), retry, true);
758
+ live.forEach((i, j) => rootHashes.set(i, roots[j]));
759
+ const build = () => this.retrying(() => this.createTasks({
760
+ token: ctx.token,
761
+ tasks: live.map((i) => {
762
+ const { token: _token, ...task } = createTaskBody(run.posts[i], run.sealed[i], ctx.token, rootHashes.get(i));
763
+ return task;
764
+ }),
765
+ }), retry);
766
+ let built;
767
+ try {
768
+ built = await build();
769
+ }
770
+ catch (err) {
771
+ // All or nothing: the rows the backend refused fail, and the rest are built again, once.
772
+ const refused = refusedRows(err, live.length);
773
+ if (!refused)
774
+ throw err;
775
+ const bad = new Set(refused.map((r) => r.index));
776
+ for (const r of refused)
777
+ await run.settle({ index: live[r.index], status: 'failed', error: { code: r.code, message: r.message } });
778
+ live = live.filter((_, j) => !bad.has(j));
779
+ if (live.length === 0)
780
+ return {};
781
+ built = await build();
782
+ }
783
+ call = this.checkedCreateTasksCall(ctx, built, live.map((i) => ({ post: run.posts[i], taskHash: run.sealed[i].taskHash })));
784
+ await this.approveRemaining(run);
785
+ // A createTasks costs about 200k gas per task. The limit is estimated
786
+ // here, after the approve it depends on, with headroom; a backend's gas
787
+ // field is never forwarded (security audit run 1, C41). An estimate that
788
+ // fails is a revert predicted before anything is sent.
789
+ const estimate = await ctx.signer.estimateGas({ to: call.to, data: call.data, ...(run.nonce !== undefined ? { nonce: run.nonce } : {}) });
790
+ gasLimit = (estimate * BATCH_GAS_HEADROOM_PCT) / 100n;
791
+ }
792
+ catch (err) {
793
+ // An escrow that refuses createTasks after all: these rows go one by one.
794
+ if (err instanceof ApiError && err.code === 'BATCH_UNSUPPORTED')
795
+ return { fallback: live };
796
+ for (const i of live)
797
+ await run.settle(failedRow(i, err));
798
+ return haltsBeforeFunding(err) ? { halt: haltAt(live[0], err) } : {};
799
+ }
800
+ const indexParams = new Map(live.map((i) => [i, indexParamsFor(run.posts[i], run.sealed[i], rootHashes.get(i))]));
801
+ const funded = live.map((i) => ({ index: i, indexParams: indexParams.get(i) }));
802
+ let txHash;
803
+ try {
804
+ const sent = await sendAndWait(ctx.signer, call, {
805
+ nonce: run.nonce,
806
+ gasLimit,
807
+ timeoutMs: run.timeoutMs,
808
+ onSent: (hash, nonce, raw) => this.notifyFunded(run, funded, hash, nonce, true, raw),
809
+ unconfirmedHint: (hash) => `If it confirms, call indexTasks() with txHash '${hash}' to list these tasks; do not fund them again.`,
810
+ });
811
+ run.nonce = sent.nonce + 1;
812
+ txHash = sent.hash;
813
+ }
814
+ catch (err) {
815
+ run.nonce = undefined;
816
+ if (err instanceof UnconfirmedTransactionError) {
817
+ for (const i of live)
818
+ await run.settle(unlistedRow(i, err.hash, true, indexParams.get(i), run.sealed[i].aesKey, { code: 'UNCONFIRMED', message: err.message }));
819
+ return { halt: haltAt(live[0], err, 'UNCONFIRMED') };
820
+ }
821
+ for (const i of live)
822
+ await run.settle(failedRow(i, err));
823
+ return { halt: haltAt(live[0], err) };
824
+ }
825
+ for (const params of indexParams.values())
826
+ params.txHash = txHash;
827
+ let listed;
828
+ try {
829
+ listed = await this.retrying(() => this.indexTasks({
830
+ txHash,
831
+ tasks: live.map((i) => {
832
+ const { txHash: _tx, ...task } = indexParams.get(i);
833
+ return task;
834
+ }),
835
+ }), retry, { receiptLag: true });
836
+ }
837
+ catch (err) {
838
+ for (const i of live)
839
+ await run.settle(unlistedRow(i, txHash, true, indexParams.get(i), run.sealed[i].aesKey, errorInfo(err)));
840
+ return { halt: haltAt(live[0], err) };
841
+ }
842
+ const byHash = new Map((Array.isArray(listed?.results) ? listed.results : []).map((r) => [String(r?.taskHash).toLowerCase(), r]));
843
+ let firstUnlisted;
844
+ for (const i of live) {
845
+ const sealed = run.sealed[i];
846
+ const r = byHash.get(sealed.taskHash.toLowerCase());
847
+ if (r && 'indexed' in r && r.indexed) {
848
+ await run.settle({ index: i, status: 'posted', task: this.postedTask(ctx, run.posts[i], sealed, rootHashes.get(i), txHash, r.onChainTaskId) });
849
+ continue;
850
+ }
851
+ const error = r && 'error' in r && r.error ? r.error : { code: 'NOT_LISTED', message: 'The backend did not say it listed this task.' };
852
+ await run.settle(unlistedRow(i, txHash, true, indexParams.get(i), sealed.aesKey, error));
853
+ firstUnlisted ??= { index: i, ...(error.code ? { code: error.code } : {}), message: `funded in ${txHash} but not listed: ${error.message}` };
854
+ }
855
+ return firstUnlisted ? { halt: firstUnlisted } : {};
856
+ }
857
+ /**
858
+ * Store briefs (base64) and return their root hashes, in order. Batched
859
+ * (upload-batch), they go UPLOAD_GROUP to a request, one request after the
860
+ * other; a group that fails transiently (isTransientUpload) is sent again
861
+ * one brief per request, each with the backoff, and a brief already stored
862
+ * comes back at once. Unbatched, each brief is one /storage/upload request
863
+ * with the backoff. A refusal (a 400) or an answer that does not add up
864
+ * fails at once.
865
+ */
866
+ async uploadBriefs(blobs, retry, batched) {
867
+ const one = (blob) => this.retrying(() => (batched ? this.uploadBatchRequest([blob]) : this.uploadOneRequest(blob)), retry, { retryable: isTransientUpload });
868
+ const roots = [];
869
+ for (let i = 0; i < blobs.length; i += batched ? UPLOAD_GROUP : 1) {
870
+ const group = blobs.slice(i, i + (batched ? UPLOAD_GROUP : 1));
871
+ if (group.length === 1) {
872
+ roots.push(...(await one(group[0])));
873
+ continue;
874
+ }
875
+ try {
876
+ roots.push(...(await this.uploadBatchRequest(group)));
877
+ }
878
+ catch (err) {
879
+ if (!isTransientUpload(err))
880
+ throw err;
881
+ // A slow storage node pushed the pair past the deadline: one at a time.
882
+ for (const blob of group)
883
+ roots.push(...(await one(blob)));
884
+ }
885
+ }
886
+ return roots;
887
+ }
888
+ /** One /storage/upload-batch request, checked to answer one root hash per brief. */
889
+ async uploadBatchRequest(blobs) {
890
+ const res = await this.req('POST', '/api/v1/storage/upload-batch', { items: blobs.map((data) => ({ data })) }, { timeoutMs: UPLOAD_TIMEOUT_MS, strictBody: true });
891
+ const results = Array.isArray(res?.results) ? res.results : [];
892
+ if (results.length !== blobs.length || results.some((r) => typeof r?.rootHash !== 'string' || !r.rootHash)) {
893
+ throw new ApiError(0, `The backend answered ${results.length} uploads for ${blobs.length} briefs. Nothing was sent.`, undefined, 'UPLOAD_MISMATCH');
894
+ }
895
+ return results.map((r) => r.rootHash);
896
+ }
897
+ /** One /storage/upload request, as postTask() stores a brief. */
898
+ async uploadOneRequest(blob) {
899
+ const res = await this.req('POST', '/api/v1/storage/upload', { data: blob }, { timeoutMs: UPLOAD_TIMEOUT_MS, strictBody: true });
900
+ if (typeof res?.rootHash !== 'string' || !res.rootHash) {
901
+ throw new ApiError(0, 'The backend stored the brief but answered no root hash. Nothing was sent.', undefined, 'UPLOAD_MISMATCH');
902
+ }
903
+ return [res.rootHash];
904
+ }
905
+ /**
906
+ * `fn`, asked again after a rate limit, a 5xx or a network error (and, for
907
+ * a listing, while the backend's RPC has not seen the receipt), or after
908
+ * what `retryable` says is worth another try.
909
+ */
910
+ async retrying(fn, policy, opts = {}) {
911
+ for (let attempt = 1;; attempt++) {
912
+ try {
913
+ return await fn();
914
+ }
915
+ catch (err) {
916
+ const again = opts.retryable
917
+ ? opts.retryable(err)
918
+ : isTransient(err) || (!!opts.receiptLag && err instanceof ApiError && err.code === 'RECEIPT_NOT_FOUND');
919
+ if (!again || attempt >= policy.attempts)
920
+ throw err;
921
+ await new Promise((r) => setTimeout(r, Math.min(policy.baseDelayMs * 2 ** (attempt - 1), MAX_RETRY_DELAY_MS)));
922
+ }
923
+ }
924
+ }
367
925
  /**
368
926
  * List a funded task on the market (`POST /api/v1/a2a/tasks/index`), from
369
927
  * its funding transaction. Safe to call again for the same task: the
@@ -1262,6 +1820,7 @@ export class BlindMarket {
1262
1820
  }
1263
1821
  export { ethers };
1264
1822
  export { ApiError };
1823
+ export { SETTLEMENT_PINS, isPinnedSettlement } from './settlementPins.js';
1265
1824
  export { tools, createBlindMarketTools, createTaskTools, createAgentManagementTools, createA2ATools, toLangChainTools, toVercelTools, toOpenAITools, toClaudeTools, } from './tools/index.js';
1266
1825
  export * from './executor/index.js';
1267
1826
  export * from './types.js';