@blindmarket/sdk 0.6.4 → 0.7.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 +74 -0
- package/README.md +59 -24
- package/dist/apiError.d.ts +26 -0
- package/dist/apiError.js +31 -0
- package/dist/index.d.ts +347 -23
- package/dist/index.js +532 -28
- package/dist/onchain.d.ts +60 -0
- package/dist/onchain.js +122 -0
- package/dist/tools/helpers.js +7 -7
- package/dist/types.d.ts +8 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
import { ethers } from 'ethers';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
this.body = body;
|
|
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
|
+
/** A whole number of base units from a string or bigint; throws 400 INVALID_AMOUNT otherwise. */
|
|
6
|
+
function wholeNumber(value, name) {
|
|
7
|
+
if (typeof value === 'bigint')
|
|
8
|
+
return value;
|
|
9
|
+
if (typeof value === 'string' && /^\d+$/.test(value))
|
|
10
|
+
return BigInt(value);
|
|
11
|
+
throw new ApiError(400, `${name} must be a whole number of the token's smallest unit (USDC has 6 decimals: '2500000' is 2.5 USDC), not ${JSON.stringify(value)}. Nothing was sent.`, undefined, 'INVALID_AMOUNT');
|
|
16
12
|
}
|
|
17
13
|
// ── Main client ─────────────────────────────────────────────────────────────
|
|
18
14
|
/**
|
|
@@ -86,7 +82,10 @@ export class BlindMarket {
|
|
|
86
82
|
});
|
|
87
83
|
const json = await res.json();
|
|
88
84
|
if (!json.success) {
|
|
89
|
-
|
|
85
|
+
const err = new ApiError(res.status, json.error?.message ?? `HTTP ${res.status}`, json, json.error?.code);
|
|
86
|
+
if (typeof json.error?.reason === 'string')
|
|
87
|
+
err.reason = json.error.reason;
|
|
88
|
+
throw err;
|
|
90
89
|
}
|
|
91
90
|
return json.data;
|
|
92
91
|
}
|
|
@@ -125,16 +124,20 @@ export class BlindMarket {
|
|
|
125
124
|
return this.req('POST', `/api/v1/tasks/${taskId}/assign`, { worker });
|
|
126
125
|
}
|
|
127
126
|
/**
|
|
128
|
-
* Build an unsigned `cancelTask` transaction
|
|
127
|
+
* Build an unsigned `cancelTask` transaction (the refund of a task no one
|
|
128
|
+
* has taken). `chain`/`chainId` name where to send it. cancelAndRefund()
|
|
129
|
+
* builds, signs and sends it for you.
|
|
129
130
|
*/
|
|
130
|
-
async cancelTask(taskId) {
|
|
131
|
-
|
|
131
|
+
async cancelTask(taskId, chain) {
|
|
132
|
+
// Task ids collide across chains: naming the chain builds for that one.
|
|
133
|
+
return this.req('POST', `/api/v1/tasks/${taskId}/cancel`, chain ? { chain } : undefined);
|
|
132
134
|
}
|
|
133
135
|
/**
|
|
134
|
-
* Build an unsigned `claimTimeout` transaction
|
|
136
|
+
* Build an unsigned `claimTimeout` transaction (the refund of a task whose
|
|
137
|
+
* deadline passed). reclaimAfterTimeout() builds, signs and sends it for you.
|
|
135
138
|
*/
|
|
136
|
-
async claimTimeout(taskId) {
|
|
137
|
-
return this.req('POST', `/api/v1/tasks/${taskId}/timeout
|
|
139
|
+
async claimTimeout(taskId, chain) {
|
|
140
|
+
return this.req('POST', `/api/v1/tasks/${taskId}/timeout`, chain ? { chain } : undefined);
|
|
138
141
|
}
|
|
139
142
|
/**
|
|
140
143
|
* Build an unsigned `submitEvidence` transaction.
|
|
@@ -142,10 +145,308 @@ export class BlindMarket {
|
|
|
142
145
|
async submitEvidence(params) {
|
|
143
146
|
return this.req('POST', '/api/v1/submissions/submit', params);
|
|
144
147
|
}
|
|
148
|
+
// ── Posting a task end to end ─────────────────────────────────────────────
|
|
149
|
+
/**
|
|
150
|
+
* Where new tasks are posted and what each chain settles in
|
|
151
|
+
* (`GET /health/settlement`): no auth, no RPC reads on the backend.
|
|
152
|
+
*/
|
|
153
|
+
async getSettlement() {
|
|
154
|
+
return this.req('GET', '/health/settlement');
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Post a task end to end, from the API key's own wallet: encrypt the brief
|
|
158
|
+
* (unless public) and wrap its key to the posting chain's executors, upload
|
|
159
|
+
* it, build createTask, approve the escrow for the amount when the token is
|
|
160
|
+
* an ERC-20, fund the escrow, and list the task (`POST /a2a/tasks/index`).
|
|
161
|
+
*
|
|
162
|
+
* The wallet signs locally, on the backend's posting chain (Arc on
|
|
163
|
+
* production, where gas is paid in USDC). Before anything is sent it checks
|
|
164
|
+
* the signer is the API key's owner, that its RPC is on the posting chain,
|
|
165
|
+
* that the wallet holds the amount, and that the backend built the tx for
|
|
166
|
+
* the escrow it advertises. The funding hash goes to `onFunded` as soon as
|
|
167
|
+
* it is sent; an error after that carries it as `err.txHash`, and
|
|
168
|
+
* indexTask() lists the funded task without paying again.
|
|
169
|
+
*
|
|
170
|
+
* @example
|
|
171
|
+
* const task = await bb.postTask(
|
|
172
|
+
* { instructions: 'Summarise this paper in 5 bullets: …', amountRaw: '2000000' }, // 2 USDC
|
|
173
|
+
* { onFunded: ({ txHash }) => saveSomewhere(txHash) },
|
|
174
|
+
* );
|
|
175
|
+
*/
|
|
176
|
+
async postTask(params, opts = {}) {
|
|
177
|
+
const amount = wholeNumber(params.amountRaw, 'amountRaw');
|
|
178
|
+
if (amount <= 0n)
|
|
179
|
+
throw new ApiError(400, 'amountRaw must be above 0. Nothing was sent.', undefined, 'INVALID_AMOUNT');
|
|
180
|
+
if (opts.maxAmountRaw !== undefined && amount > BigInt(opts.maxAmountRaw)) {
|
|
181
|
+
throw new ApiError(402, `The escrow of ${amount} is above your limit of ${opts.maxAmountRaw}. Nothing was sent.`, undefined, 'AMOUNT_ABOVE_MAX');
|
|
182
|
+
}
|
|
183
|
+
const duration = params.durationSeconds ?? 86_400;
|
|
184
|
+
if (!Number.isInteger(duration) || duration < 3_600 || duration > 90 * 86_400) {
|
|
185
|
+
throw new ApiError(400, 'durationSeconds must be a whole number from 3600 (1 hour) to 7776000 (90 days): the escrow refuses anything else. Nothing was sent.', undefined, 'INVALID_DURATION');
|
|
186
|
+
}
|
|
187
|
+
const privacy = params.privacy ?? 'private';
|
|
188
|
+
const verificationMode = params.verificationMode ?? 'auto';
|
|
189
|
+
const verificationCriteria = params.verificationCriteria
|
|
190
|
+
?? (verificationMode === 'auto' ? { min_length: 10, pass_threshold: 60 } : undefined);
|
|
191
|
+
const requiredCapabilities = params.requiredCapabilities ?? [];
|
|
192
|
+
// Where the escrow is funded, and in what.
|
|
193
|
+
const { postingChain, chains } = await this.getSettlement();
|
|
194
|
+
const entry = chains.find((c) => c.chain === postingChain);
|
|
195
|
+
if (!postingChain || !entry || !entry.escrowAddress || !entry.token.address) {
|
|
196
|
+
throw new ApiError(503, `The backend has no chain to post new tasks on right now (posting chain: ${postingChain ?? 'none'}). Nothing was sent.`, { postingChain, chains }, 'SETTLEMENT_NOT_POSTABLE');
|
|
197
|
+
}
|
|
198
|
+
const escrow = entry.escrowAddress;
|
|
199
|
+
const token = entry.token.address;
|
|
200
|
+
const isNative = entry.token.kind === 'native';
|
|
201
|
+
const signer = opts.signer ?? this.signerOn(postingChain, 'Funding the escrow');
|
|
202
|
+
const poster = await signer.getAddress();
|
|
203
|
+
await this.assertSpender(poster, 'A task', true);
|
|
204
|
+
await assertSignerChain(signer, entry.chainId, `Funding the escrow on ${postingChain}`);
|
|
205
|
+
if (!isNative) {
|
|
206
|
+
const balance = await tokenBalance(signer, token, poster);
|
|
207
|
+
if (balance < amount) {
|
|
208
|
+
const fmt = (v) => ethers.formatUnits(v, entry.token.decimals);
|
|
209
|
+
throw new ApiError(402, `${poster} holds ${fmt(balance)} ${entry.token.symbol} on ${postingChain}; the escrow needs ${fmt(amount)}. Nothing was sent.`, undefined, 'INSUFFICIENT_BALANCE');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// The brief: plaintext, or encrypted to the executors that can take it.
|
|
213
|
+
const plaintext = new TextEncoder().encode(params.instructions);
|
|
214
|
+
let blob;
|
|
215
|
+
let wrappedKeys;
|
|
216
|
+
let aesKey;
|
|
217
|
+
if (privacy === 'public') {
|
|
218
|
+
blob = plaintext;
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
const qs = new URLSearchParams({ capabilities: requiredCapabilities.join(','), chain: postingChain });
|
|
222
|
+
const { executors } = await this.req('GET', `/api/v1/a2a/executors?${qs}`);
|
|
223
|
+
let targets = executors.filter((e) => typeof e.publicKey === 'string' && e.publicKey.length > 0);
|
|
224
|
+
if (params.targetExecutor) {
|
|
225
|
+
const want = params.targetExecutor.toLowerCase();
|
|
226
|
+
targets = targets.filter((e) => e.address.toLowerCase() === want);
|
|
227
|
+
if (targets.length === 0) {
|
|
228
|
+
throw new ApiError(404, `${params.targetExecutor} is not a registered executor on ${postingChain} with a public key, so it could not read the brief. Nothing was sent.`, undefined, 'EXECUTOR_NOT_FOUND');
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (targets.length > 200) {
|
|
232
|
+
throw new ApiError(409, `${targets.length} executors match, more than the 200 a brief can be wrapped to. Narrow requiredCapabilities, name a targetExecutor, or post with privacy 'public'. Nothing was sent.`, undefined, 'TOO_MANY_EXECUTORS');
|
|
233
|
+
}
|
|
234
|
+
const key = await generateAesKey();
|
|
235
|
+
blob = await aesEncrypt(plaintext, key);
|
|
236
|
+
wrappedKeys = {};
|
|
237
|
+
for (const e of targets) {
|
|
238
|
+
try {
|
|
239
|
+
wrappedKeys[e.address.toLowerCase()] = bytesToHex(await eciesEncrypt(key, e.publicKey));
|
|
240
|
+
}
|
|
241
|
+
catch { /* a malformed public key: that executor can't be wrapped to */ }
|
|
242
|
+
}
|
|
243
|
+
if (Object.keys(wrappedKeys).length === 0) {
|
|
244
|
+
throw new ApiError(409, `No executor on ${postingChain} can decrypt an encrypted brief right now, so no one could take the task. Post with privacy 'public', or wait for executors to register. Nothing was sent.`, undefined, 'NO_EXECUTORS');
|
|
245
|
+
}
|
|
246
|
+
aesKey = bytesToHex(key);
|
|
247
|
+
}
|
|
248
|
+
const taskHash = `0x${bytesToHex(await sha256(blob))}`;
|
|
249
|
+
const { rootHash } = await this.uploadBlob(ethers.encodeBase64(blob));
|
|
250
|
+
const built = await this.createTask({
|
|
251
|
+
taskHash: taskHash,
|
|
252
|
+
token: token,
|
|
253
|
+
amount: amount.toString(),
|
|
254
|
+
locationZone: params.locationZone ?? 'global',
|
|
255
|
+
duration: String(duration),
|
|
256
|
+
targetExecutorType: 'agent',
|
|
257
|
+
verificationMode,
|
|
258
|
+
...(verificationCriteria ? { verificationCriteria } : {}),
|
|
259
|
+
...(params.verifierAddress ? { verifierAddress: params.verifierAddress } : {}),
|
|
260
|
+
requiredCapabilities,
|
|
261
|
+
rootHash,
|
|
262
|
+
...(wrappedKeys ? { wrappedKeys } : {}),
|
|
263
|
+
});
|
|
264
|
+
// The tx must go to the escrow and chain checked above: a backend whose
|
|
265
|
+
// posting chain moved in between would otherwise have it signed blind.
|
|
266
|
+
if ((built.chain !== undefined && built.chain !== postingChain) || (built.chainId !== undefined && Number(built.chainId) !== entry.chainId)) {
|
|
267
|
+
throw new ApiError(409, `The backend built this task for ${built.chain} (chain ${built.chainId}), not ${postingChain}: its posting chain changed. Nothing was sent; try again.`, undefined, 'POSTING_CHAIN_CHANGED');
|
|
268
|
+
}
|
|
269
|
+
if (built.unsignedTx.to.toLowerCase() !== escrow.toLowerCase()) {
|
|
270
|
+
throw new ApiError(409, `The backend built this task for ${built.unsignedTx.to}, not the ${postingChain} escrow ${escrow}. Nothing was sent.`, undefined, 'ESCROW_MISMATCH');
|
|
271
|
+
}
|
|
272
|
+
const timeoutMs = opts.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS;
|
|
273
|
+
// 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
|
+
};
|
|
288
|
+
let txHash;
|
|
289
|
+
try {
|
|
290
|
+
({ hash: txHash } = await sendAndWait(signer, { to: built.unsignedTx.to, data: built.unsignedTx.data }, {
|
|
291
|
+
value: isNative ? amount : undefined,
|
|
292
|
+
nonce,
|
|
293
|
+
timeoutMs,
|
|
294
|
+
onSent: (hash) => opts.onFunded?.({ txHash: hash, taskHash, indexParams: { ...indexParams, txHash: hash } }),
|
|
295
|
+
unconfirmedHint: (hash) => `If it confirms, call indexTask() with txHash '${hash}' to list the task; do not fund it again.`,
|
|
296
|
+
}));
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
if (err instanceof UnconfirmedTransactionError) {
|
|
300
|
+
const out = new ApiError(0, err.message, { indexParams: { ...indexParams, txHash: err.hash } }, 'UNCONFIRMED');
|
|
301
|
+
out.txHash = err.hash;
|
|
302
|
+
throw out;
|
|
303
|
+
}
|
|
304
|
+
throw err;
|
|
305
|
+
}
|
|
306
|
+
indexParams.txHash = txHash;
|
|
307
|
+
let indexed;
|
|
308
|
+
try {
|
|
309
|
+
indexed = await this.indexTaskPatiently(indexParams);
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
const e = err;
|
|
313
|
+
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);
|
|
314
|
+
out.txHash = txHash;
|
|
315
|
+
throw out;
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
taskHash,
|
|
319
|
+
...(indexed.onChainTaskId !== undefined ? { taskId: String(indexed.onChainTaskId) } : {}),
|
|
320
|
+
txHash,
|
|
321
|
+
chain: postingChain,
|
|
322
|
+
chainId: entry.chainId,
|
|
323
|
+
rootHash,
|
|
324
|
+
privacy,
|
|
325
|
+
wrappedTo: wrappedKeys ? Object.keys(wrappedKeys).length : 0,
|
|
326
|
+
...(aesKey ? { aesKey } : {}),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* List a funded task on the market (`POST /api/v1/a2a/tasks/index`), from
|
|
331
|
+
* its funding transaction. Safe to call again for the same task: the
|
|
332
|
+
* backend merges a repeat from the same poster. postTask() calls it; call
|
|
333
|
+
* it yourself to finish a post whose funding confirmed but whose listing
|
|
334
|
+
* failed (the error's `body.indexParams` holds the fields).
|
|
335
|
+
*/
|
|
336
|
+
async indexTask(params) {
|
|
337
|
+
return this.req('POST', '/api/v1/a2a/tasks/index', params);
|
|
338
|
+
}
|
|
339
|
+
/** indexTask(), asking again while the backend's RPC has not seen the receipt or the backend is briefly down. */
|
|
340
|
+
async indexTaskPatiently(params) {
|
|
341
|
+
for (let attempt = 1;; attempt++) {
|
|
342
|
+
try {
|
|
343
|
+
return await this.indexTask(params);
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
const transient = err instanceof ApiError
|
|
347
|
+
? err.code === 'RECEIPT_NOT_FOUND' || err.status >= 500
|
|
348
|
+
: err instanceof TypeError; // fetch failed: the network, not the request
|
|
349
|
+
if (!transient || attempt >= 4)
|
|
350
|
+
throw err;
|
|
351
|
+
await new Promise((r) => setTimeout(r, 3_000));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Cancel a task no one has taken and get its escrow back: builds
|
|
357
|
+
* cancelTask, checks the signer is on the task's chain, signs and sends it,
|
|
358
|
+
* then takes the task off the market (`POST /tasks/:id/confirm-tx`).
|
|
359
|
+
* `taskId` is the on-chain id (PostedTask.taskId); pass `chain`
|
|
360
|
+
* (PostedTask.chain) too, since ids repeat across chains.
|
|
361
|
+
*/
|
|
362
|
+
async cancelAndRefund(taskId, opts = {}) {
|
|
363
|
+
return this.sendRefund(taskId, await this.cancelTask(taskId, opts.chain), 'Cancelling the task', opts);
|
|
364
|
+
}
|
|
365
|
+
/** Reclaim the escrow of a task whose deadline passed undelivered (claimTimeout), signed and sent. */
|
|
366
|
+
async reclaimAfterTimeout(taskId, opts = {}) {
|
|
367
|
+
return this.sendRefund(taskId, await this.claimTimeout(taskId, opts.chain), 'Reclaiming the escrow', opts);
|
|
368
|
+
}
|
|
369
|
+
async sendRefund(taskId, built, what, opts) {
|
|
370
|
+
const { chain, chainId } = built;
|
|
371
|
+
if (!chain || chainId === undefined) {
|
|
372
|
+
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
|
+
}
|
|
374
|
+
const tx = built.unsignedTx;
|
|
375
|
+
const signer = opts.signer ?? this.signerOn(chain, what);
|
|
376
|
+
await assertSignerChain(signer, chainId, what);
|
|
377
|
+
let hash;
|
|
378
|
+
try {
|
|
379
|
+
({ hash } = await sendAndWait(signer, { to: tx.to, data: tx.data }, { timeoutMs: opts.confirmTimeoutMs }));
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
if (err instanceof UnconfirmedTransactionError) {
|
|
383
|
+
const out = new ApiError(0, `${err.message} Check it before sending another.`, { txHash: err.hash }, 'UNCONFIRMED');
|
|
384
|
+
out.txHash = err.hash;
|
|
385
|
+
throw out;
|
|
386
|
+
}
|
|
387
|
+
throw err;
|
|
388
|
+
}
|
|
389
|
+
return { txHash: hash, chain, chainId, listingClosed: await this.confirmRefund(taskId, hash, chain) };
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Tell the backend a refund landed (`POST /api/v1/tasks/:id/confirm-tx`),
|
|
393
|
+
* which checks the receipt and takes the task off the market. Without it a
|
|
394
|
+
* refunded task keeps listing as open until its deadline. Best effort: the
|
|
395
|
+
* money has already moved, so a failure here only reports false.
|
|
396
|
+
*/
|
|
397
|
+
async confirmRefund(taskId, txHash, chain) {
|
|
398
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
399
|
+
try {
|
|
400
|
+
await this.req('POST', `/api/v1/tasks/${taskId}/confirm-tx`, { txHash, chain });
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
catch (err) {
|
|
404
|
+
// The backend's RPC can lag the receipt the signer just saw.
|
|
405
|
+
if (!(err instanceof ApiError && err.code === 'NOT_CONFIRMED') || attempt === 3)
|
|
406
|
+
return false;
|
|
407
|
+
await new Promise((r) => setTimeout(r, 3_000));
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
145
412
|
// ── Agent deployment & management ─────────────────────────────────────────
|
|
413
|
+
/** What deploying an agent costs on this backend, and how to pay it. */
|
|
414
|
+
async getDeployFee() {
|
|
415
|
+
return this.req('GET', '/api/v1/agents/deploy-fee');
|
|
416
|
+
}
|
|
146
417
|
/**
|
|
147
|
-
*
|
|
148
|
-
*
|
|
418
|
+
* Run every check POST /deploy makes before it takes a fee, with nothing
|
|
419
|
+
* paid or saved. Throws the same ApiError the deploy would (400 with field
|
|
420
|
+
* errors, 404 SKILL_NOT_FOUND, 400 INVALID_OWNER_PUBLIC_KEY). Returns false
|
|
421
|
+
* when the backend predates the check and nothing could be checked.
|
|
422
|
+
*/
|
|
423
|
+
async validateDeploy(params) {
|
|
424
|
+
const { ownerAddress: _ignored, ...body } = params;
|
|
425
|
+
try {
|
|
426
|
+
await this.req('POST', '/api/v1/agents/deploy/validate', body);
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
catch (err) {
|
|
430
|
+
if (err instanceof SyntaxError || (err instanceof ApiError && err.status === 404 && err.code !== 'SKILL_NOT_FOUND'))
|
|
431
|
+
return false;
|
|
432
|
+
throw err;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Deploy a new hosted agent. The backend generates its wallet, mints an
|
|
437
|
+
* INFT, starts it, and returns the agent descriptor.
|
|
438
|
+
*
|
|
439
|
+
* Deploying costs a fee (1 USDC on Arc on production; getDeployFee() says).
|
|
440
|
+
* An unspent AgentFactory credit pays first. Otherwise deployAgent() pays
|
|
441
|
+
* only with `{ payFee: true }`, from the configured executor wallet (set
|
|
442
|
+
* `rpcUrls.arc`) or `payer`, which must be the API key's owner. Before it
|
|
443
|
+
* pays it checks the payer's chain, the fee against `maxFeeRaw`, and the
|
|
444
|
+
* request itself, so nothing is paid for a deploy that would be refused.
|
|
445
|
+
*
|
|
446
|
+
* The fee's hash goes to `onFeePaid` as soon as it is sent, and onto any
|
|
447
|
+
* error after that (`err.feeTxHash`): retry with `params.feeTxHash` set to
|
|
448
|
+
* it and nothing is paid twice. A retry whose payment already created one
|
|
449
|
+
* of your agents returns that agent, with `alreadyDeployed: true`.
|
|
149
450
|
*
|
|
150
451
|
* @example
|
|
151
452
|
* const agent = await bb.deployAgent({
|
|
@@ -154,13 +455,178 @@ export class BlindMarket {
|
|
|
154
455
|
* provider: 'anthropic',
|
|
155
456
|
* model: 'claude-sonnet-4-5',
|
|
156
457
|
* apiKey: process.env.ANTHROPIC_API_KEY!,
|
|
157
|
-
* ownerAddress: wallet.address,
|
|
158
458
|
* // Uncompressed, no 0x (`wallet` is an ethers Wallet; its `publicKey` is compressed).
|
|
159
459
|
* ownerPublicKey: wallet.signingKey.publicKey.slice(2),
|
|
160
|
-
* });
|
|
460
|
+
* }, { payFee: true, onFeePaid: (hash) => saveSomewhere(hash) });
|
|
461
|
+
*/
|
|
462
|
+
async deployAgent(params, opts = {}) {
|
|
463
|
+
const { ownerAddress: _ignored, feeTxHash: given, ...rest } = params;
|
|
464
|
+
const body = rest;
|
|
465
|
+
const pollMs = opts.pollIntervalMs ?? 5_000;
|
|
466
|
+
// A named payment: the backend may still be waiting for its receipt.
|
|
467
|
+
if (given)
|
|
468
|
+
return this.deployWithFee(body, given, pollMs);
|
|
469
|
+
let terms;
|
|
470
|
+
try {
|
|
471
|
+
terms = await this.getDeployFee();
|
|
472
|
+
}
|
|
473
|
+
catch (err) {
|
|
474
|
+
// A backend from before the fee route: deploy as the SDK always did.
|
|
475
|
+
if (err instanceof SyntaxError || (err instanceof ApiError && err.status === 404))
|
|
476
|
+
return this.postDeploy(body, [], 1, pollMs);
|
|
477
|
+
throw err;
|
|
478
|
+
}
|
|
479
|
+
if (!terms.required)
|
|
480
|
+
return this.postDeploy(body, [], 1, pollMs);
|
|
481
|
+
// An unspent AgentFactory credit pays before anything new is spent. This
|
|
482
|
+
// POST also runs every check the deploy makes: a request it would refuse
|
|
483
|
+
// fails here, before a payment.
|
|
484
|
+
try {
|
|
485
|
+
return await this.postDeploy(body, [], 1, pollMs);
|
|
486
|
+
}
|
|
487
|
+
catch (err) {
|
|
488
|
+
if (!(err instanceof ApiError && err.code === 'NO_DEPLOY_CREDIT'))
|
|
489
|
+
throw err;
|
|
490
|
+
if (!opts.payFee) {
|
|
491
|
+
const cost = terms.method === 'transfer'
|
|
492
|
+
? `${ethers.formatUnits(BigInt(terms.amountRaw), terms.decimals).replace(/\.0$/, '')} USDC on ${terms.chain}`
|
|
493
|
+
: `a fee through AgentFactory on ${terms.chain}`;
|
|
494
|
+
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');
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
// Everything checkable is checked before anything is paid.
|
|
498
|
+
if (body.provider !== '0g-compute' && !body.apiKey) {
|
|
499
|
+
throw new ApiError(400, `A ${body.provider} agent needs params.apiKey to call its model. Nothing was paid.`, undefined, 'API_KEY_REQUIRED');
|
|
500
|
+
}
|
|
501
|
+
if (terms.chainId === undefined) {
|
|
502
|
+
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');
|
|
503
|
+
}
|
|
504
|
+
const maxFee = BigInt(opts.maxFeeRaw ?? 1000000n);
|
|
505
|
+
const payer = opts.payer ?? this.signerOn(terms.chain, 'Paying the deploy fee');
|
|
506
|
+
const payerAddress = await payer.getAddress();
|
|
507
|
+
await this.assertFeePayer(payerAddress);
|
|
508
|
+
await assertSignerChain(payer, terms.chainId, 'The deploy fee');
|
|
509
|
+
const timeoutMs = opts.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS;
|
|
510
|
+
if (terms.method === 'transfer') {
|
|
511
|
+
const fee = BigInt(terms.amountRaw);
|
|
512
|
+
this.assertFeeCeiling(fee, maxFee, terms.decimals);
|
|
513
|
+
const data = new ethers.Interface(['function transfer(address to, uint256 amount) returns (bool)'])
|
|
514
|
+
.encodeFunctionData('transfer', [terms.recipient, fee]);
|
|
515
|
+
let hash;
|
|
516
|
+
try {
|
|
517
|
+
({ hash } = await sendAndWait(payer, { to: terms.token, data }, {
|
|
518
|
+
onSent: opts.onFeePaid,
|
|
519
|
+
timeoutMs,
|
|
520
|
+
unconfirmedHint: (h) => `If it confirms, retry with params.feeTxHash = '${h}' so the fee is not paid twice.`,
|
|
521
|
+
}));
|
|
522
|
+
}
|
|
523
|
+
catch (err) {
|
|
524
|
+
if (err instanceof UnconfirmedTransactionError)
|
|
525
|
+
throw this.withFee(err, err.hash, 'UNCONFIRMED');
|
|
526
|
+
throw err;
|
|
527
|
+
}
|
|
528
|
+
return this.deployWithFee(body, hash, pollMs);
|
|
529
|
+
}
|
|
530
|
+
if (!terms.factory)
|
|
531
|
+
throw new ApiError(503, 'This backend charges through AgentFactory but names no factory address.', { terms }, 'DEPLOY_FEE_UNAVAILABLE');
|
|
532
|
+
const factory = new ethers.Interface(['function deployAgent(uint256 usdcAmount)', 'function deployFeeUsdc() view returns (uint256)', 'function usdc() view returns (address)']);
|
|
533
|
+
const reader = payer.provider;
|
|
534
|
+
const [fee] = factory.decodeFunctionResult('deployFeeUsdc', await reader.call({ to: terms.factory, data: factory.encodeFunctionData('deployFeeUsdc') }));
|
|
535
|
+
const [token] = factory.decodeFunctionResult('usdc', await reader.call({ to: terms.factory, data: factory.encodeFunctionData('usdc') }));
|
|
536
|
+
this.assertFeeCeiling(fee, maxFee, 6);
|
|
537
|
+
const nonce = await ensureAllowance(payer, token, terms.factory, fee, { timeoutMs });
|
|
538
|
+
let factoryTx;
|
|
539
|
+
try {
|
|
540
|
+
({ hash: factoryTx } = await sendAndWait(payer, { to: terms.factory, data: factory.encodeFunctionData('deployAgent', [0]) }, { nonce, timeoutMs }));
|
|
541
|
+
}
|
|
542
|
+
catch (err) {
|
|
543
|
+
if (err instanceof UnconfirmedTransactionError) {
|
|
544
|
+
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');
|
|
545
|
+
}
|
|
546
|
+
throw err;
|
|
547
|
+
}
|
|
548
|
+
// The backend indexes the factory every 15s: the credit lags the payment.
|
|
549
|
+
try {
|
|
550
|
+
return { ...(await this.postDeploy(body, ['NO_DEPLOY_CREDIT'], 20, pollMs)), feeTxHash: factoryTx };
|
|
551
|
+
}
|
|
552
|
+
catch (err) {
|
|
553
|
+
const e = err;
|
|
554
|
+
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);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/** Deploy with a fee transaction already paid: wait for the backend to see it, and make a retry safe. */
|
|
558
|
+
async deployWithFee(body, feeTxHash, pollMs) {
|
|
559
|
+
try {
|
|
560
|
+
const agent = await this.postDeploy({ ...body, feeTxHash }, ['DEPLOY_FEE_NOT_FOUND', 'DEPLOY_FEE_IN_USE', 'DEPLOY_FEE_CHECK_FAILED'], 4, pollMs);
|
|
561
|
+
return { ...agent, feeTxHash };
|
|
562
|
+
}
|
|
563
|
+
catch (err) {
|
|
564
|
+
// This payment already created an agent: a retry after a lost response.
|
|
565
|
+
// Return it when it is the caller's.
|
|
566
|
+
if (err instanceof ApiError && err.code === 'DEPLOY_FEE_ALREADY_USED') {
|
|
567
|
+
const agentId = err.body?.error?.agentId;
|
|
568
|
+
const existing = agentId ? await this.ownAgent(agentId).catch(() => null) : null;
|
|
569
|
+
if (existing)
|
|
570
|
+
return { ...existing, feeTxHash, alreadyDeployed: true };
|
|
571
|
+
}
|
|
572
|
+
throw this.withFee(err, feeTxHash);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
/** `agentId` as a DeployedAgent, when the API key's owner owns it; else null. */
|
|
576
|
+
async ownAgent(agentId) {
|
|
577
|
+
const [agent, who] = await Promise.all([this.getAgent(agentId), this.whoami()]);
|
|
578
|
+
const mine = new Set([who.address, ...(who.addresses ?? [])].map((a) => String(a).toLowerCase()));
|
|
579
|
+
if (!agent?.ownerAddress || !mine.has(agent.ownerAddress.toLowerCase()))
|
|
580
|
+
return null;
|
|
581
|
+
const { id, name, walletAddress, publicKey, inftTokenId, status } = agent;
|
|
582
|
+
return { id, name, walletAddress, publicKey, status, ...(inftTokenId !== undefined ? { inftTokenId } : {}) };
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* An error after the fee was paid, carrying the payment: `feeTxHash` on the
|
|
586
|
+
* error and in its body, and the message says how to reuse it. The
|
|
587
|
+
* backend's code, status and envelope are kept.
|
|
161
588
|
*/
|
|
162
|
-
|
|
163
|
-
|
|
589
|
+
withFee(err, feeTxHash, fallbackCode) {
|
|
590
|
+
const e = err;
|
|
591
|
+
const spent = err instanceof ApiError && ['DEPLOY_FEE_ALREADY_USED', 'DEPLOY_FEE_NOT_PAID', 'DEPLOY_FEE_REVERTED'].includes(err.code ?? '');
|
|
592
|
+
const message = spent || e.message.includes(feeTxHash)
|
|
593
|
+
? e.message
|
|
594
|
+
: `${e.message} — the deploy fee is paid (transaction ${feeTxHash}); retry with params.feeTxHash = '${feeTxHash}' so it is not paid twice.`;
|
|
595
|
+
const body = e.body && typeof e.body === 'object' ? { ...e.body, feeTxHash } : { feeTxHash };
|
|
596
|
+
const out = new ApiError(e.status ?? 0, message, body, e.code ?? fallbackCode);
|
|
597
|
+
if (err instanceof ApiError && err.reason)
|
|
598
|
+
out.reason = err.reason;
|
|
599
|
+
out.feeTxHash = feeTxHash;
|
|
600
|
+
return out;
|
|
601
|
+
}
|
|
602
|
+
assertFeeCeiling(fee, maxFee, decimals) {
|
|
603
|
+
if (fee <= maxFee)
|
|
604
|
+
return;
|
|
605
|
+
const fmt = (v) => ethers.formatUnits(v, decimals).replace(/\.0$/, '');
|
|
606
|
+
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');
|
|
607
|
+
}
|
|
608
|
+
/** POST /agents/deploy, asking again while the backend answers one of `retryCodes`. */
|
|
609
|
+
async postDeploy(body, retryCodes, attempts, pollMs) {
|
|
610
|
+
for (let i = 1;; i++) {
|
|
611
|
+
try {
|
|
612
|
+
return await this.req('POST', '/api/v1/agents/deploy', body);
|
|
613
|
+
}
|
|
614
|
+
catch (err) {
|
|
615
|
+
if (!(err instanceof ApiError) || !retryCodes.includes(err.code ?? '') || i >= attempts)
|
|
616
|
+
throw err;
|
|
617
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
/** The configured executor as a signer on `chain`. */
|
|
622
|
+
signerOn(chain, what) {
|
|
623
|
+
if (!this.executor) {
|
|
624
|
+
throw new ApiError(400, `${what} needs a signer: set BlindMarketConfig.executor, or pass one in the options.`, undefined, 'NO_SIGNER');
|
|
625
|
+
}
|
|
626
|
+
const rpc = this.executor.rpcUrls[chain];
|
|
627
|
+
if (!rpc)
|
|
628
|
+
throw new ApiError(400, `${what} happens on ${chain}, but no RPC is configured for it — set rpcUrls.${chain}.`, undefined, 'NO_RPC');
|
|
629
|
+
return new ethers.Wallet(this.executor.privateKey, new ethers.JsonRpcProvider(rpc));
|
|
164
630
|
}
|
|
165
631
|
/**
|
|
166
632
|
* One-shot executor registration in the A2A marketplace.
|
|
@@ -257,6 +723,30 @@ export class BlindMarket {
|
|
|
257
723
|
}
|
|
258
724
|
return true;
|
|
259
725
|
}
|
|
726
|
+
/**
|
|
727
|
+
* Before a spend: throw 409 OWNER_MISMATCH unless `address` is a wallet the
|
|
728
|
+
* backend will credit the spend to. `exact` needs the API key's own address
|
|
729
|
+
* (a task is posted as that wallet); otherwise any wallet linked to it
|
|
730
|
+
* counts, as the deploy fee check does. Unlike assertOwnerKey this fails
|
|
731
|
+
* closed: money never moves on an unchecked wallet.
|
|
732
|
+
*/
|
|
733
|
+
async assertSpender(address, what, exact) {
|
|
734
|
+
let who;
|
|
735
|
+
try {
|
|
736
|
+
who = await this.whoami();
|
|
737
|
+
}
|
|
738
|
+
catch (err) {
|
|
739
|
+
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');
|
|
740
|
+
}
|
|
741
|
+
const allowed = new Set([who.address, ...(exact ? [] : who.addresses ?? [])].filter((a) => typeof a === 'string').map((a) => a.toLowerCase()));
|
|
742
|
+
if (!allowed.has(address.toLowerCase())) {
|
|
743
|
+
throw new ApiError(409, `This API key belongs to ${who.address} but the signer is ${address}. Nothing was sent. ` +
|
|
744
|
+
`${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');
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
assertFeePayer(address) {
|
|
748
|
+
return this.assertSpender(address, 'A deploy fee', false);
|
|
749
|
+
}
|
|
260
750
|
/** List deployed agents, optionally filtered by owner address. */
|
|
261
751
|
async listAgents(ownerAddress) {
|
|
262
752
|
const qs = ownerAddress ? `?owner=${ownerAddress}` : '';
|
|
@@ -431,6 +921,16 @@ export class BlindMarket {
|
|
|
431
921
|
return { ...(await this.finalize(taskId)), submitTxHash };
|
|
432
922
|
}
|
|
433
923
|
}
|
|
924
|
+
/**
|
|
925
|
+
* Approve or reject the delivered result of a task you posted with
|
|
926
|
+
* `verificationMode: 'manual'` (`POST /api/v1/a2a/tasks/:hash/verify`).
|
|
927
|
+
* Approving settles the escrow to the worker (90%); rejecting fails the
|
|
928
|
+
* round, and the worker may resubmit before the deadline. Only the poster
|
|
929
|
+
* can review, and only once the task is `submitted`.
|
|
930
|
+
*/
|
|
931
|
+
async reviewResult(taskHash, review) {
|
|
932
|
+
return this.req('POST', `/api/v1/a2a/tasks/${encodeURIComponent(taskHash)}/verify`, review);
|
|
933
|
+
}
|
|
434
934
|
/** Get tasks posted by the authenticated user. */
|
|
435
935
|
async getPostedTasks() {
|
|
436
936
|
return this.req('GET', '/api/v1/a2a/tasks/posted');
|
|
@@ -470,7 +970,11 @@ export class BlindMarket {
|
|
|
470
970
|
return this.req('GET', `/api/v1/reputation/leaderboard?limit=${limit}`);
|
|
471
971
|
}
|
|
472
972
|
// ── Storage ─────────────────────────────────────────────────────────────
|
|
473
|
-
/**
|
|
973
|
+
/**
|
|
974
|
+
* Upload a blob to 0G Storage. `data` is the bytes as **base64**: the
|
|
975
|
+
* backend base64-decodes it. (The type once said Hex; a hex string sent
|
|
976
|
+
* here uploads the wrong bytes.)
|
|
977
|
+
*/
|
|
474
978
|
async uploadBlob(data) {
|
|
475
979
|
return this.req('POST', '/api/v1/storage/upload', { data });
|
|
476
980
|
}
|