@blindmarket/sdk 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,106 @@
3
3
  This package is 0.x: a minor version may contain breaking changes. They are
4
4
  listed here with how to migrate.
5
5
 
6
+ ## 0.9.0
7
+
8
+ ### New
9
+
10
+ - **`postTasks(rows, opts)` posts many tasks at once** (docs/BULK-POSTING.md).
11
+ Every row is checked and its brief sealed before anything is uploaded or
12
+ sent. A row the escrow or the backend would refuse throws 400
13
+ `INVALID_ROWS`, listing each one in `err.body.errors` (`{ index, code,
14
+ message }`), with nothing sent. Rows that are the same public brief count
15
+ as refused (`DUPLICATE_BRIEF`). The wallet must hold the total, and the
16
+ escrow is approved for it once, just before the first funding transaction,
17
+ instead of once per task.
18
+ - **Escrow with `createTasks`:** `GET /health/settlement` reports
19
+ `batchCreate.supported` for the chain. Up to `chunkSize` rows (default
20
+ 20, at most the escrow's `maxBatch`) then share one transaction and one
21
+ listing call. The transaction's gas limit is estimated locally with 20%
22
+ headroom.
23
+ - **Otherwise:** each row is its own `createTask`, as `postTask()` sends it.
24
+ - **Safety:** every transaction is checked before signing, as `postTask()`
25
+ checks one. A `createTasks` must hold exactly these tasks, in this order,
26
+ for this token, escrow and chain.
27
+ - **Where the run stops:** a row the backend refuses before funding fails
28
+ alone and the run goes on. A funding that reverts or cannot be confirmed,
29
+ a listing that fails, or a backend that builds the wrong transaction or
30
+ stays unreachable stops the run there (`result.stopped`).
31
+ - **No double funding:** a funded row that is not listed comes back
32
+ `'unlisted'` with its `indexParams`, and nothing is funded twice.
33
+ - **Brief storage:** briefs are stored two per `upload-batch` request, one
34
+ request after another (0G stores a brief in 20–40 s, and production's
35
+ edge gives up at ~100 s).
36
+ - A pair that fails transiently is re-sent one brief per request, in
37
+ order, each with the backoff; a stored brief returns at once. Transient
38
+ means: a dropped connection, this client's own timeout, 429, 502, 503,
39
+ 504, or Cloudflare's non-JSON 524.
40
+ - A refusal (400) or an answer that doesn't add up fails at once.
41
+ - A chunk is funded only after every one of its briefs is stored.
42
+ - **Options:** `onFunded` fires per row the moment its transaction is
43
+ broadcast, with `batch: true` when the row shares a transaction.
44
+ `onProgress` reports each row. `signal` stops before the next row.
45
+ `retry` sets the backoff for 429s, 5xx and network errors. A funding
46
+ transaction is never re-sent.
47
+ - `indexTasks({ txHash, tasks })` lists every task one transaction funded
48
+ (`POST /a2a/tasks/index-batch`). Rows `postTasks()` left unlisted with
49
+ `batch: true` must be finished with it: the single index route refuses a
50
+ receipt that funded several tasks.
51
+ - `createTasks({ token, tasks })` builds a `createTasks` transaction
52
+ (`POST /tasks/batch`). `uploadBlobs(data[])` uploads several blobs at once
53
+ (`POST /storage/upload-batch`).
54
+ - `SettlementChainInfo.batchCreate?: { supported, maxBatch }`.
55
+ - `onFunded` (in `postTask()` and `postTasks()`) now also gets the funding
56
+ transaction's `nonce`. Save it with the hash: if the transaction never shows
57
+ a receipt and the sender's confirmed nonce has moved past it, the
58
+ transaction can never land, and nothing was escrowed. With a local key,
59
+ the signed `raw` transaction comes too. While its nonce is unused it can be
60
+ re-broadcast as is, and it can only land once.
61
+ - `PostTaskParams.routingSummary`: the public one-liner the task board shows
62
+ for a task, which is all a private task shows. It's sent with the listing
63
+ and checked for length (500) before anything is funded
64
+ (`INVALID_ROUTING_SUMMARY`).
65
+
66
+ ### Security
67
+
68
+ - **Only a known escrow is funded.** `postTask()` and `postTasks()` approve
69
+ and fund only the escrow and settlement token pinned for the posting chain
70
+ (`SETTLEMENT_PINS`: Arc mainnet 5042 and Arc Testnet 5042002). Before, both
71
+ came from `/health/settlement`. Any other answer throws 409
72
+ `ESCROW_NOT_PINNED` with nothing approved or sent. For a custom or local
73
+ deployment, list it in `BlindMarketConfig.trustedEscrows`
74
+ (`{ chainId, escrow, token }`). `network/presets.ts` is unchanged.
75
+ - **A local key signs, records, then broadcasts.** An ethers `Wallet` (a
76
+ signer holding its own key) is signed first, and `onSent` / `onFunded` get
77
+ the hash and nonce before the raw transaction goes out. A broadcast whose
78
+ answer is lost is `UnconfirmedTransactionError` (it may still land), never
79
+ "nothing sent". Browser wallets still sign and send in one step.
80
+ - **The category is bound.** The calldata check requires `'general'`, the
81
+ category the backend builds, like every other argument of `createTask` and
82
+ `createTasks`.
83
+
84
+ ### Internal
85
+
86
+ - `postTask()` and `postTasks()` share one set of row checks and brief
87
+ sealing (`src/posting.ts`). `postTask()` behaves exactly as before.
88
+
89
+ ## 0.8.1
90
+
91
+ ### Behaviour changes
92
+
93
+ - **`WorkerRuntime` accepts a task only where its RPC is on the backend's
94
+ network.** Before each `/accept` it checks that its RPC for the task's chain
95
+ answers the chain id `GET /health/settlement` lists for that chain. A chain
96
+ keeps its key when the backend moves it to another network (Arc Testnet
97
+ 5042002, Arc mainnet 5042). A runtime still on the old network used to accept,
98
+ which assigns the task on-chain for good, and then fail `submitEvidence` with
99
+ `WRONG_CHAIN`. On a mismatch it now skips the task (`task_failed`, "not
100
+ accepted: …") and looks at it again after a back-off that grows from 30 s to
101
+ an hour. Only an answer that matches is remembered, so one wrong answer from
102
+ the RPC does not hold a chain back. A chain it cannot check, because the RPC
103
+ or the backend cannot be read or the chain is not listed, holds nothing
104
+ back: `deliverResult()` checks again before it signs.
105
+
6
106
  ## 0.8.0
7
107
 
8
108
  ### Breaking / behaviour changes
package/README.md CHANGED
@@ -151,7 +151,9 @@ its RPC is on the posting chain, and that the wallet holds the amount.
151
151
  ```ts
152
152
  const bb = new BlindMarket({
153
153
  apiKey: process.env.BLINDMARKET_API_KEY!, // an sk_ key minted while signed in as OWNER
154
- executor: { privateKey: process.env.OWNER_PRIVATE_KEY!, rpcUrls: { arc: 'https://rpc.testnet.arc.io' } },
154
+ // An RPC on the network /health/settlement names for arc:
155
+ // https://arc-rpc.publicnode.com (Arc mainnet, 5042) or https://arc-testnet-rpc.publicnode.com (Arc Testnet, 5042002).
156
+ executor: { privateKey: process.env.OWNER_PRIVATE_KEY!, rpcUrls: { arc: process.env.ARC_RPC_URL! } },
155
157
  });
156
158
 
157
159
  const task = await bb.postTask(
@@ -185,6 +187,19 @@ The lower-level builders are unchanged: `createTask()`, `cancelTask()` and
185
187
  `claimTimeout()` return unsigned transactions, now with the `chain` and
186
188
  `chainId` to send them on.
187
189
 
190
+ **Which escrow it funds.** `postTask()` and `postTasks()` fund only the
191
+ escrow and token pinned for the posting chain (`SETTLEMENT_PINS`: Arc mainnet
192
+ and Arc Testnet), whatever the backend names. Anything else throws
193
+ `ESCROW_NOT_PINNED` before anything is approved. For a custom or local
194
+ deployment:
195
+
196
+ ```ts
197
+ const bb = new BlindMarket({
198
+ apiKey,
199
+ trustedEscrows: [{ chainId: 5042002, escrow: '0x…yourEscrow', token: '0x3600000000000000000000000000000000000000' }],
200
+ });
201
+ ```
202
+
188
203
  **What the client signs.** `postTask()`, `cancelAndRefund()`,
189
204
  `reclaimAfterTimeout()` and `deliverResult()` sign transactions the backend
190
205
  builds, so each one is decoded and checked first: it must be exactly the call
@@ -204,6 +219,45 @@ const detail = await bb.getTask(taskId);
204
219
  const { postingChain, chains } = await bb.getSettlement(); // where tasks are posted, and in what token
205
220
  ```
206
221
 
222
+ ### Posting many tasks
223
+
224
+ `postTasks()` posts a list, for example 500 rows from a spreadsheet. Before
225
+ anything is uploaded or sent, it:
226
+
227
+ - checks every row;
228
+ - seals every brief;
229
+ - checks that the wallet holds the total.
230
+
231
+ A row that would be refused throws `INVALID_ROWS`, naming each one in
232
+ `err.body.errors`. The escrow is approved once, for the total.
233
+
234
+ - **On an escrow with `createTasks`:** `getSettlement()` shows
235
+ `batchCreate.supported`. Up to `chunkSize` tasks (default 20) then share
236
+ one transaction.
237
+ - **Otherwise:** each task is its own transaction.
238
+
239
+ Every transaction is checked before signing, as `postTask()` checks one.
240
+
241
+ ```ts
242
+ const res = await bb.postTasks(rows, { // rows: PostTaskParams[]
243
+ onFunded: ({ taskHash, indexParams, batch }) => save(taskHash, { indexParams, batch }),
244
+ onProgress: ({ done, total }) => console.log(`${done}/${total}`),
245
+ });
246
+ console.log(res.posted, res.unlisted, res.failed, res.skipped, res.stopped);
247
+ ```
248
+
249
+ **Failures:**
250
+ - **Before funding:** a row the backend refuses fails alone, and the run goes
251
+ on.
252
+ - **At or after funding:** a funding that reverts or can't be confirmed, or a
253
+ listing that fails, stops the run there. That way no more escrow is funded
254
+ behind a problem.
255
+
256
+ **Recovery:** nothing is funded twice. A funded row that isn't listed comes
257
+ back `'unlisted'` with its `indexParams`. Finish it with
258
+ `indexTask(indexParams)`, or with `indexTasks()` when its `batch` is true (the
259
+ tasks share one transaction). Cancel it with `cancelAndRefund()` for a refund.
260
+
207
261
  ### Agent management
208
262
 
209
263
  Deploying a hosted agent costs a fee: 1 USDC on Arc on production (`bb.getDeployFee()` says what this backend charges). An unspent AgentFactory credit pays first. Otherwise `deployAgent()` pays only when asked (`payFee: true`), from the API key owner's wallet: the configured `executor` (with `rpcUrls.arc`) or a `payer` signer.
@@ -249,8 +303,9 @@ await bb.updateAgent(agentId, {
249
303
  const bb = new BlindMarket({
250
304
  apiKey,
251
305
  // Optional: the API key owner's wallet + an RPC per chain your tasks settle
252
- // on. Enables deliverResult() and the submit_result tool.
253
- executor: { privateKey, rpcUrls: { arc: 'https://rpc.testnet.arc.io', base: 'https://sepolia.base.org' } },
306
+ // on, each on the network /health/settlement names for that chain. Enables
307
+ // deliverResult() and the submit_result tool.
308
+ executor: { privateKey, rpcUrls: { arc: process.env.ARC_RPC_URL!, base: process.env.BASE_RPC_URL! } },
254
309
  });
255
310
 
256
311
  // Register as an executor. The executor ADDRESS is always the API key's owner
@@ -321,8 +376,10 @@ const runtime = new WorkerRuntime({
321
376
  // the owner's.
322
377
  privateKey: process.env.EXECUTOR_PRIVATE_KEY!,
323
378
  // REQUIRED: at least one RPC, on the network your `apiBase` settles on.
324
- // There is NO default. Production posts new tasks on Arc (Arc Testnet,
325
- // https://rpc.testnet.arc.io); without `rpcUrls.arc` the runtime skips them.
379
+ // There is NO default. Production posts new tasks on Arc; without
380
+ // `rpcUrls.arc` the runtime skips them. Use the network /health/settlement
381
+ // names for arc: https://arc-rpc.publicnode.com (Arc mainnet, 5042) or
382
+ // https://arc-testnet-rpc.publicnode.com (Arc Testnet, 5042002).
326
383
  // `base` covers older Base Sepolia tasks. `rpcUrl` is the 0G RPC only and
327
384
  // never stands in for another chain.
328
385
  rpcUrls: { arc: process.env.ARC_RPC_URL!, base: process.env.BASE_RPC_URL! },
@@ -340,7 +397,13 @@ random wallet's public key over the owner's on every `start()`, accepted tasks
340
397
  a default runtime accepted mainnet tasks and failed ethers' chainId pin after
341
398
  assignment. Both now fail at `start()`, before any request. To only look at
342
399
  tasks, call `bb.browseA2ATasks()` — it needs neither. Use RPCs for the network
343
- your backend settles on (testnet backend → testnet RPCs).
400
+ your backend settles on (testnet backend → testnet RPCs). A chain keeps its
401
+ name when the backend moves it to another network (`arc` is Arc Testnet or
402
+ Arc mainnet), so before each accept the runtime checks that its RPC for the
403
+ task's chain answers the chain id `/health/settlement` lists. An accept assigns
404
+ the task on-chain for good, so while they differ it takes none of that chain's
405
+ tasks (`task_failed`, "not accepted: …"), and looks at each again after a
406
+ back-off. Point that RPC at the network the backend names.
344
407
 
345
408
  `existingPrivateKey` (instead of `privateKey`) restores a runtime without
346
409
  re-registering: the stored profile is kept, and `start()` throws if the key is
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The only transactions a backend may hand this client to sign.
3
3
  *
4
- * The backend builds createTask, submitEvidence, cancelTask and claimTimeout
4
+ * The backend builds createTask (or createTasks), submitEvidence, cancelTask and claimTimeout
5
5
  * for the client's own key to sign. Whoever answers at `apiBase` (a
6
6
  * compromised or malicious backend, an untrusted apiBase, a network attacker
7
7
  * on plain http) controls that JSON, so a client that signs it as given signs
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { ethers } from 'ethers';
20
20
  export declare const ESCROW_CALLS: ethers.Interface;
21
- export type EscrowFunction = 'createTask' | 'createTaskWithVerifier' | 'submitEvidence' | 'cancelTask' | 'claimTimeout';
21
+ export type EscrowFunction = 'createTask' | 'createTaskWithVerifier' | 'createTasks' | 'submitEvidence' | 'cancelTask' | 'claimTimeout';
22
22
  /**
23
23
  * The evidence hash the backend commits for a result: keccak256 of the UTF-8
24
24
  * JSON of `resultData`, exactly as POST /a2a/tasks/:id/submit computes it
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The only transactions a backend may hand this client to sign.
3
3
  *
4
- * The backend builds createTask, submitEvidence, cancelTask and claimTimeout
4
+ * The backend builds createTask (or createTasks), submitEvidence, cancelTask and claimTimeout
5
5
  * for the client's own key to sign. Whoever answers at `apiBase` (a
6
6
  * compromised or malicious backend, an untrusted apiBase, a network attacker
7
7
  * on plain http) controls that JSON, so a client that signs it as given signs
@@ -21,6 +21,8 @@ import { ApiError } from './apiError.js';
21
21
  export const ESCROW_CALLS = new ethers.Interface([
22
22
  'function createTask(bytes32 taskHash, address token, uint256 amount, string category, string locationZone, uint256 duration)',
23
23
  'function createTaskWithVerifier(bytes32 taskHash, address token, uint256 amount, string category, string locationZone, uint256 duration, address verifierAgent)',
24
+ // Several tasks in one transaction, on an escrow that has it (docs/BULK-POSTING.md).
25
+ 'function createTasks(address token, tuple(bytes32 taskHash, uint256 amount, string category, string locationZone, uint256 duration, address verifierAgent)[] tasks)',
24
26
  'function submitEvidence(uint256 taskId, bytes32 evidenceHash)',
25
27
  'function cancelTask(uint256 taskId)',
26
28
  'function claimTimeout(uint256 taskId)',
@@ -167,6 +167,8 @@ export declare class WorkerRuntime {
167
167
  private listeners;
168
168
  /** Set once the runtime has said it skips listings with no recorded reward. */
169
169
  private warnedNoReward;
170
+ /** The chain id each configured RPC answered, by URL, kept once it matched the backend's. */
171
+ private rpcChainIds;
170
172
  constructor(config: WorkerRuntimeConfig);
171
173
  get isRunning(): boolean;
172
174
  get isPaused(): boolean;
@@ -237,6 +239,16 @@ export declare class WorkerRuntime {
237
239
  */
238
240
  private claim;
239
241
  private retryState;
242
+ /**
243
+ * Why a task on `chain` must not be accepted, or null: this runtime's RPC
244
+ * for the chain answers another chain id than the one the backend settles
245
+ * it on (GET /health/settlement). A chain keeps its key when the backend
246
+ * moves it to another network (Arc Testnet 5042002, Arc mainnet 5042), and
247
+ * submitEvidence can only be signed where the task is. Nothing is held back
248
+ * when either side cannot be read or the backend does not list the chain:
249
+ * deliverResult() checks the chain again before it signs.
250
+ */
251
+ private wrongNetwork;
240
252
  /**
241
253
  * POST /accept, re-trying while the backend says the claim is still ours:
242
254
  * 503 ASSIGNMENT_PENDING means the assign tx is broadcast but unconfirmed
@@ -118,6 +118,8 @@ export class WorkerRuntime {
118
118
  listeners = new Set();
119
119
  /** Set once the runtime has said it skips listings with no recorded reward. */
120
120
  warnedNoReward = false;
121
+ /** The chain id each configured RPC answered, by URL, kept once it matched the backend's. */
122
+ rpcChainIds = new Map();
121
123
  constructor(config) {
122
124
  this.config = { ...DEFAULTS, ...config };
123
125
  this.bb = new BlindMarket({ apiKey: config.apiKey, apiBase: config.apiBase });
@@ -442,6 +444,40 @@ export class WorkerRuntime {
442
444
  return retry;
443
445
  }
444
446
  // ── Accept ──────────────────────────────────────────────────────────────
447
+ /**
448
+ * Why a task on `chain` must not be accepted, or null: this runtime's RPC
449
+ * for the chain answers another chain id than the one the backend settles
450
+ * it on (GET /health/settlement). A chain keeps its key when the backend
451
+ * moves it to another network (Arc Testnet 5042002, Arc mainnet 5042), and
452
+ * submitEvidence can only be signed where the task is. Nothing is held back
453
+ * when either side cannot be read or the backend does not list the chain:
454
+ * deliverResult() checks the chain again before it signs.
455
+ */
456
+ async wrongNetwork(chain) {
457
+ const known = SETTLEMENT_CHAINS.find((c) => c === chain);
458
+ const rpc = known ? rpcFor(this.config, known) : undefined;
459
+ if (!rpc)
460
+ return null;
461
+ try {
462
+ const { chains } = await this.bb.getSettlement();
463
+ const listed = chains.find((c) => c.chain === chain)?.chainId;
464
+ if (listed === undefined || !Number.isInteger(listed))
465
+ return null;
466
+ // A match is kept: a URL's network does not change under it. Anything
467
+ // else is asked again next time, so one wrong answer is not kept.
468
+ if (this.rpcChainIds.get(rpc) === BigInt(listed))
469
+ return null;
470
+ const served = (await new ethers.JsonRpcProvider(rpc).getNetwork()).chainId;
471
+ if (served === BigInt(listed)) {
472
+ this.rpcChainIds.set(rpc, served);
473
+ return null;
474
+ }
475
+ return `the RPC for ${chain} serves chain ${served}, but the backend settles ${chain} on chain ${listed}, where this runtime could not sign its submitEvidence. Point it at chain ${listed}.`;
476
+ }
477
+ catch {
478
+ return null;
479
+ }
480
+ }
445
481
  /**
446
482
  * POST /accept, re-trying while the backend says the claim is still ours:
447
483
  * 503 ASSIGNMENT_PENDING means the assign tx is broadcast but unconfirmed
@@ -587,6 +623,19 @@ export class WorkerRuntime {
587
623
  if (!exec)
588
624
  return;
589
625
  try {
626
+ // An accept assigns on-chain for good, so not on a network this
627
+ // runtime's RPC is not on. The task is looked at again after a back-off
628
+ // that grows while the answer stays wrong.
629
+ const wrong = await this.wrongNetwork(meta?.chain);
630
+ if (wrong) {
631
+ this.executions.delete(taskId);
632
+ const retry = this.retryState(taskId);
633
+ retry.reaccept = undefined;
634
+ retry.failures++;
635
+ retry.notBefore = Date.now() + backoff(RELEASED_BACKOFF_MS, retry.failures, MAX_BACKOFF_MS);
636
+ this.emit({ type: 'task_failed', taskId, error: `not accepted: ${wrong}` });
637
+ return;
638
+ }
590
639
  // Accept task — get the rootHash + this executor's ECIES-wrapped AES
591
640
  // key. wrappedKey is a single hex string (this caller's slice), not a
592
641
  // Record — see acceptTask()'s doc comment in ../index.ts.