@blindmarket/sdk 0.7.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,15 +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
- /** 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');
4
+ import { checkEscrowCall, evidenceHashOf, taskIdOf } from './escrowCalls.js';
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) };
12
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(',');
13
123
  // ── Main client ─────────────────────────────────────────────────────────────
14
124
  /**
15
125
  * BlindMarket REST API client.
@@ -35,10 +145,12 @@ export class BlindMarket {
35
145
  apiBase;
36
146
  apiKey;
37
147
  executor;
148
+ trustedEscrows;
38
149
  constructor(config) {
39
150
  this.apiBase = config.apiBase ?? 'https://api.blindmarket.xyz';
40
151
  this.apiKey = config.apiKey;
41
152
  this.executor = config.executor;
153
+ this.trustedEscrows = config.trustedEscrows ?? [];
42
154
  }
43
155
  /** True when an executor signer was configured (see BlindMarketConfig.executor). */
44
156
  get canSign() {
@@ -71,16 +183,40 @@ export class BlindMarket {
71
183
  * generateText({ model, tools: tools(bb).vercel });
72
184
  * ```
73
185
  */
74
- async req(method, path, body) {
75
- const res = await fetch(`${this.apiBase}${path}`, {
76
- method,
77
- headers: {
78
- 'Content-Type': 'application/json',
79
- Authorization: `Bearer ${this.apiKey}`,
80
- },
81
- body: body ? JSON.stringify(body) : undefined,
82
- });
83
- 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
+ }
84
220
  if (!json.success) {
85
221
  const err = new ApiError(res.status, json.error?.message ?? `HTTP ${res.status}`, json, json.error?.code);
86
222
  if (typeof json.error?.reason === 'string')
@@ -99,7 +235,11 @@ export class BlindMarket {
99
235
  return this.req('GET', '/api/v1/stats');
100
236
  }
101
237
  // ── Task lifecycle ──────────────────────────────────────────────────────
102
- /** List open tasks (human-readable). */
238
+ /**
239
+ * List open tasks from the legacy 0G TaskRegistry (numeric ids on the 0G
240
+ * escrow). Tasks escrowed on Base or Arc are not in it: browseA2ATasks()
241
+ * lists the work agents can take.
242
+ */
103
243
  async listTasks(limit = 20) {
104
244
  const { tasks } = await this.req('GET', `/api/v1/tasks?limit=${limit}`);
105
245
  return tasks;
@@ -135,6 +275,9 @@ export class BlindMarket {
135
275
  /**
136
276
  * Build an unsigned `claimTimeout` transaction (the refund of a task whose
137
277
  * deadline passed). reclaimAfterTimeout() builds, signs and sends it for you.
278
+ * `outcome` says what it will do: on work delivered before the deadline and
279
+ * never judged, the escrow sends the task for review ('escalate') instead
280
+ * of refunding it, and `message` explains.
138
281
  */
139
282
  async claimTimeout(taskId, chain) {
140
283
  return this.req('POST', `/api/v1/tasks/${taskId}/timeout`, chain ? { chain } : undefined);
@@ -153,6 +296,19 @@ export class BlindMarket {
153
296
  async getSettlement() {
154
297
  return this.req('GET', '/health/settlement');
155
298
  }
299
+ /**
300
+ * `chain`'s entry in /health/settlement, with its escrow: every transaction
301
+ * the backend builds for this client to sign must target that escrow.
302
+ * Throws 409 CHAIN_UNKNOWN when the backend lists no escrow for it.
303
+ */
304
+ async settlementEntry(chain, what, settlement) {
305
+ const { chains } = settlement ?? await this.getSettlement();
306
+ const entry = chains.find((c) => c.chain === chain);
307
+ if (!entry?.escrowAddress || !Number.isInteger(entry.chainId)) {
308
+ 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');
309
+ }
310
+ return entry;
311
+ }
156
312
  /**
157
313
  * Post a task end to end, from the API key's own wallet: encrypt the brief
158
314
  * (unless public) and wrap its key to the posting chain's executors, upload
@@ -162,8 +318,10 @@ export class BlindMarket {
162
318
  * The wallet signs locally, on the backend's posting chain (Arc on
163
319
  * production, where gas is paid in USDC). Before anything is sent it checks
164
320
  * 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
321
+ * that the wallet holds the amount, and that the backend built exactly this
322
+ * createTask (task hash, token, amount, zone, duration) for the escrow it
323
+ * advertises, with no other value: 409 ESCROW_MISMATCH / TX_MISMATCH
324
+ * otherwise. Only the tx's to and data are signed. The funding hash goes to `onFunded` as soon as
167
325
  * it is sent; an error after that carries it as `err.txHash`, and
168
326
  * indexTask() lists the funded task without paying again.
169
327
  *
@@ -174,124 +332,27 @@ export class BlindMarket {
174
332
  * );
175
333
  */
176
334
  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
- }
335
+ const post = normalizePost(params, opts.maxAmountRaw);
336
+ const ctx = await this.postingContext(opts.signer);
337
+ await this.assertCovers(ctx, post.amount);
212
338
  // 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
- }
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);
272
345
  const timeoutMs = opts.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS;
273
346
  // 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
- };
347
+ const nonce = ctx.isNative ? undefined : await ensureAllowance(ctx.signer, ctx.token, ctx.escrow, post.amount, { timeoutMs });
348
+ const indexParams = indexParamsFor(post, sealed, rootHash);
288
349
  let txHash;
289
350
  try {
290
- ({ hash: txHash } = await sendAndWait(signer, { to: built.unsignedTx.to, data: built.unsignedTx.data }, {
291
- value: isNative ? amount : undefined,
351
+ ({ hash: txHash } = await sendAndWait(ctx.signer, createCall, {
352
+ value: ctx.isNative ? post.amount : undefined,
292
353
  nonce,
293
354
  timeoutMs,
294
- 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 } }),
295
356
  unconfirmedHint: (hash) => `If it confirms, call indexTask() with txHash '${hash}' to list the task; do not fund it again.`,
296
357
  }));
297
358
  }
@@ -314,18 +375,553 @@ export class BlindMarket {
314
375
  out.txHash = txHash;
315
376
  throw out;
316
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) {
317
661
  return {
318
- taskHash,
319
- ...(indexed.onChainTaskId !== undefined ? { taskId: String(indexed.onChainTaskId) } : {}),
662
+ taskHash: sealed.taskHash,
663
+ ...(onChainTaskId !== undefined ? { taskId: String(onChainTaskId) } : {}),
320
664
  txHash,
321
- chain: postingChain,
322
- chainId: entry.chainId,
665
+ chain: ctx.postingChain,
666
+ chainId: ctx.entry.chainId,
323
667
  rootHash,
324
- privacy,
325
- wrappedTo: wrappedKeys ? Object.keys(wrappedKeys).length : 0,
326
- ...(aesKey ? { aesKey } : {}),
668
+ privacy: post.privacy,
669
+ wrappedTo: sealed.wrappedKeys ? Object.keys(sealed.wrappedKeys).length : 0,
670
+ ...(sealed.aesKey ? { aesKey: sealed.aesKey } : {}),
327
671
  };
328
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
+ }
329
925
  /**
330
926
  * List a funded task on the market (`POST /api/v1/a2a/tasks/index`), from
331
927
  * its funding transaction. Safe to call again for the same task: the
@@ -358,25 +954,54 @@ export class BlindMarket {
358
954
  * then takes the task off the market (`POST /tasks/:id/confirm-tx`).
359
955
  * `taskId` is the on-chain id (PostedTask.taskId); pass `chain`
360
956
  * (PostedTask.chain) too, since ids repeat across chains.
957
+ *
958
+ * Only a zero-value `cancelTask(taskId)` on the escrow /health/settlement
959
+ * lists for the chain is signed (to and data only), and only on the chain
960
+ * you named: 409 ESCROW_MISMATCH, TX_MISMATCH, CHAIN_MISMATCH or
961
+ * CHAIN_UNKNOWN otherwise, with nothing sent. reclaimAfterTimeout() does
962
+ * the same for `claimTimeout(taskId)`.
361
963
  */
362
964
  async cancelAndRefund(taskId, opts = {}) {
363
- return this.sendRefund(taskId, await this.cancelTask(taskId, opts.chain), 'Cancelling the task', opts);
965
+ return this.sendRefund(taskId, await this.cancelTask(taskId, opts.chain), 'cancelTask', 'Cancelling the task', opts);
364
966
  }
365
- /** Reclaim the escrow of a task whose deadline passed undelivered (claimTimeout), signed and sent. */
967
+ /**
968
+ * Reclaim the escrow of a task whose deadline passed undelivered
969
+ * (claimTimeout), signed and sent. On work delivered before the deadline
970
+ * and never judged, the escrow sends the task for review instead and
971
+ * refunds nothing: the result's outcome is then 'escalate'.
972
+ */
366
973
  async reclaimAfterTimeout(taskId, opts = {}) {
367
- return this.sendRefund(taskId, await this.claimTimeout(taskId, opts.chain), 'Reclaiming the escrow', opts);
974
+ return this.sendRefund(taskId, await this.claimTimeout(taskId, opts.chain), 'claimTimeout', 'Reclaiming the escrow', opts);
368
975
  }
369
- async sendRefund(taskId, built, what, opts) {
976
+ /**
977
+ * Sign the refund the backend built, once it is checked to be exactly
978
+ * `fn(taskId)` on the escrow of the chain it names (the one the caller
979
+ * named, when it named one), with no value. Only its to and data are signed.
980
+ */
981
+ async sendRefund(taskId, built, fn, what, opts) {
370
982
  const { chain, chainId } = built;
371
983
  if (!chain || chainId === undefined) {
372
984
  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
985
  }
374
- const tx = built.unsignedTx;
986
+ if (opts.chain && chain !== opts.chain) {
987
+ 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');
988
+ }
989
+ const entry = await this.settlementEntry(chain, what);
990
+ if (Number(chainId) !== entry.chainId) {
991
+ 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');
992
+ }
993
+ const id = taskIdOf(taskId);
994
+ const call = checkEscrowCall(built.unsignedTx, {
995
+ escrow: entry.escrowAddress,
996
+ fn,
997
+ args: (a) => id !== undefined && a[0] === id,
998
+ chainId: entry.chainId,
999
+ }, what);
375
1000
  const signer = opts.signer ?? this.signerOn(chain, what);
376
- await assertSignerChain(signer, chainId, what);
1001
+ await assertSignerChain(signer, entry.chainId, what);
377
1002
  let hash;
378
1003
  try {
379
- ({ hash } = await sendAndWait(signer, { to: tx.to, data: tx.data }, { timeoutMs: opts.confirmTimeoutMs }));
1004
+ ({ hash } = await sendAndWait(signer, call, { timeoutMs: opts.confirmTimeoutMs }));
380
1005
  }
381
1006
  catch (err) {
382
1007
  if (err instanceof UnconfirmedTransactionError) {
@@ -386,28 +1011,34 @@ export class BlindMarket {
386
1011
  }
387
1012
  throw err;
388
1013
  }
389
- return { txHash: hash, chain, chainId, listingClosed: await this.confirmRefund(taskId, hash, chain) };
1014
+ const confirmed = await this.confirmRefund(taskId, hash, chain);
1015
+ // The receipt is the authority; the build's outcome covers a backend that
1016
+ // could not confirm it.
1017
+ const outcome = confirmed.escalated ? 'escalate' : built.outcome;
1018
+ return { txHash: hash, chain, chainId, listingClosed: confirmed.closed, ...(outcome ? { outcome } : {}) };
390
1019
  }
391
1020
  /**
392
1021
  * Tell the backend a refund landed (`POST /api/v1/tasks/:id/confirm-tx`),
393
1022
  * which checks the receipt and takes the task off the market. Without it a
394
1023
  * refunded task keeps listing as open until its deadline. Best effort: the
395
- * money has already moved, so a failure here only reports false.
1024
+ * money has already moved, so a failure here only reports it not closed.
1025
+ * A claim that sent the task for review closes nothing (escalated).
396
1026
  */
397
1027
  async confirmRefund(taskId, txHash, chain) {
398
1028
  for (let attempt = 1; attempt <= 3; attempt++) {
399
1029
  try {
400
- await this.req('POST', `/api/v1/tasks/${taskId}/confirm-tx`, { txHash, chain });
401
- return true;
1030
+ const res = await this.req('POST', `/api/v1/tasks/${taskId}/confirm-tx`, { txHash, chain });
1031
+ const escalated = res?.escalated === true;
1032
+ return { closed: !escalated, escalated };
402
1033
  }
403
1034
  catch (err) {
404
1035
  // The backend's RPC can lag the receipt the signer just saw.
405
1036
  if (!(err instanceof ApiError && err.code === 'NOT_CONFIRMED') || attempt === 3)
406
- return false;
1037
+ return { closed: false, escalated: false };
407
1038
  await new Promise((r) => setTimeout(r, 3_000));
408
1039
  }
409
1040
  }
410
- return false;
1041
+ return { closed: false, escalated: false };
411
1042
  }
412
1043
  // ── Agent deployment & management ─────────────────────────────────────────
413
1044
  /** What deploying an agent costs on this backend, and how to pay it. */
@@ -867,13 +1498,34 @@ export class BlindMarket {
867
1498
  * unsigned `submitEvidence` on the chain the backend names → `finalize()`.
868
1499
  * Safe to re-call on a task stranded in 'submitted': INVALID_STATE at submit
869
1500
  * and NOT_SUBMITTED_ON_CHAIN at finalize both heal through `rebroadcast()`.
1501
+ *
1502
+ * The executor key signs only a zero-value `submitEvidence(onChainTaskId,
1503
+ * evidenceHash)` on the escrow /health/settlement lists for that chain,
1504
+ * where (from /submit) evidenceHash is keccak256 of `JSON.stringify(resultData)`,
1505
+ * over an RPC checked to serve that chain, and only its to and data.
1506
+ * Anything else throws 409 ESCROW_MISMATCH, TX_MISMATCH, CHAIN_MISMATCH or
1507
+ * CHAIN_UNKNOWN (or WRONG_CHAIN for the RPC) with nothing sent.
870
1508
  */
871
1509
  async deliverResult(taskId, resultData, signerOverride) {
872
1510
  const signer = signerOverride ?? this.executor;
873
1511
  if (!signer) {
874
1512
  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.');
875
1513
  }
876
- const send = async (built) => {
1514
+ // Read before /submit, which records the result: a lookup failing here
1515
+ // leaves nothing half-done.
1516
+ const settlement = await this.getSettlement();
1517
+ // The evidence the backend commits for this result (backend/src/routes/a2a.ts).
1518
+ const evidence = evidenceHashOf(resultData);
1519
+ const what = `Delivering task ${taskId}`;
1520
+ /**
1521
+ * Sign the submitEvidence the backend built, once it is checked to be a
1522
+ * zero-value submitEvidence on the escrow of the chain it names, for the
1523
+ * on-chain task it names, and (from /submit) committing THIS result. Only
1524
+ * its to and data are signed, on the signer's RPC for that chain, checked
1525
+ * to serve it. /rebroadcast re-sends the first stored result, so there the
1526
+ * evidence hash is not this call's.
1527
+ */
1528
+ const send = async (built, fromSubmit) => {
877
1529
  if (!built.unsignedSubmitEvidence)
878
1530
  return undefined;
879
1531
  // Absent `chain` = a backend older than the field, where every task is on 0G.
@@ -884,16 +1536,24 @@ export class BlindMarket {
884
1536
  if (!rpc) {
885
1537
  throw new Error(`task ${taskId} is escrowed on ${chain} but no RPC is configured for it — set rpcUrls.${chain}`);
886
1538
  }
887
- // The tx carries chainId, so a wrong RPC fails at ethers instead of
888
- // landing on the wrong network.
1539
+ const entry = await this.settlementEntry(chain, what, settlement);
1540
+ const onChainId = built.onChainTaskId === undefined ? undefined : taskIdOf(built.onChainTaskId);
1541
+ const call = checkEscrowCall(built.unsignedSubmitEvidence, {
1542
+ escrow: entry.escrowAddress,
1543
+ fn: 'submitEvidence',
1544
+ args: (a) => (built.onChainTaskId === undefined || a[0] === onChainId)
1545
+ && (!fromSubmit || String(a[1]).toLowerCase() === evidence),
1546
+ chainId: entry.chainId,
1547
+ }, what);
889
1548
  const wallet = new ethers.Wallet(signer.privateKey, new ethers.JsonRpcProvider(rpc));
890
- const tx = await wallet.sendTransaction(built.unsignedSubmitEvidence);
1549
+ await assertSignerChain(wallet, entry.chainId, what);
1550
+ const tx = await wallet.sendTransaction(call);
891
1551
  await tx.wait();
892
1552
  return tx.hash;
893
1553
  };
894
1554
  const healStranded = async () => {
895
1555
  try {
896
- return await send(await this.rebroadcast(taskId));
1556
+ return await send(await this.rebroadcast(taskId), false);
897
1557
  }
898
1558
  catch (err) {
899
1559
  // Evidence is already on-chain — nothing to broadcast, go finalize.
@@ -904,7 +1564,7 @@ export class BlindMarket {
904
1564
  };
905
1565
  let submitTxHash;
906
1566
  try {
907
- submitTxHash = await send(await this.submitResult(taskId, resultData));
1567
+ submitTxHash = await send(await this.submitResult(taskId, resultData), true);
908
1568
  }
909
1569
  catch (err) {
910
1570
  if (!(err instanceof ApiError && err.code === 'INVALID_STATE'))
@@ -985,7 +1645,8 @@ export class BlindMarket {
985
1645
  * which returns `{ rootHash, blob }`, not `{ data }`.
986
1646
  */
987
1647
  async downloadBlob(rootHash) {
988
- return this.req('GET', `/api/v1/storage/${rootHash}`);
1648
+ // Encoded so a rootHash can never step out of /storage/.
1649
+ return this.req('GET', `/api/v1/storage/${encodeURIComponent(rootHash)}`);
989
1650
  }
990
1651
  // ── Messages ─────────────────────────────────────────────────────────────
991
1652
  /** Send a message to another user or agent. */
@@ -1159,6 +1820,7 @@ export class BlindMarket {
1159
1820
  }
1160
1821
  export { ethers };
1161
1822
  export { ApiError };
1823
+ export { SETTLEMENT_PINS, isPinnedSettlement } from './settlementPins.js';
1162
1824
  export { tools, createBlindMarketTools, createTaskTools, createAgentManagementTools, createA2ATools, toLangChainTools, toVercelTools, toOpenAITools, toClaudeTools, } from './tools/index.js';
1163
1825
  export * from './executor/index.js';
1164
1826
  export * from './types.js';