@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/CHANGELOG.md +130 -0
- package/README.md +101 -8
- package/dist/chain/abi/BlindEscrow.json +154 -0
- package/dist/escrowCalls.d.ts +51 -0
- package/dist/escrowCalls.js +97 -0
- package/dist/executor/WorkerRuntime.d.ts +32 -0
- package/dist/executor/WorkerRuntime.js +120 -0
- package/dist/index.d.ts +354 -5
- package/dist/index.js +821 -159
- 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/dist/tools/helpers.js +1 -1
- package/dist/types.d.ts +13 -0
- package/package.json +1 -1
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 {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
/**
|
|
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
|
|
166
|
-
*
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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,
|
|
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
|
-
...(
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
888
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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';
|