@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/CHANGELOG.md +100 -0
- package/README.md +69 -6
- package/dist/escrowCalls.d.ts +2 -2
- package/dist/escrowCalls.js +3 -1
- package/dist/executor/WorkerRuntime.d.ts +12 -0
- package/dist/executor/WorkerRuntime.js +49 -0
- package/dist/index.d.ts +300 -0
- package/dist/index.js +710 -151
- package/dist/network/presets.d.ts +1 -1
- package/dist/network/presets.js +1 -1
- package/dist/onchain.d.ts +9 -5
- package/dist/onchain.js +31 -6
- package/dist/posting.d.ts +63 -0
- package/dist/posting.js +168 -0
- package/dist/settlementPins.d.ts +20 -0
- package/dist/settlementPins.js +11 -0
- package/package.json +1 -1
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
...(
|
|
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';
|