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