@blindmarket/sdk 0.6.2 → 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 CHANGED
@@ -3,6 +3,113 @@
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.7.0
7
+
8
+ ### Changes
9
+
10
+ **`postTask()` posts a task end to end on the posting chain.** Before, the SDK
11
+ only built an unsigned `createTask`, leaving the approve, the send, the index
12
+ and the chain to the caller. Nobody could post from the SDK on Arc,
13
+ production's posting chain. `postTask(params, opts)` does the whole post from
14
+ the API key owner's wallet:
15
+
16
+ - encrypts the brief (or posts it `privacy: 'public'`) and wraps its key to
17
+ the posting chain's executors
18
+ - uploads it, and approves the escrow for the amount
19
+ - funds the escrow and lists the task (`/a2a/tasks/index`)
20
+
21
+ It checks everything before anything is sent: the signer is the API key's
22
+ own wallet, its RPC is on the posting chain, the wallet holds the amount, and
23
+ the backend built the tx for the escrow it advertises. The funding hash and
24
+ the full listing body go to `onFunded` as soon as it is sent. An error after funding carries it
25
+ (`err.txHash`) and the listing body (`err.body.indexParams`), and the new
26
+ `indexTask()` finishes the listing without paying again.
27
+
28
+ **`reviewResult(taskHash, { passed, reasons })`** approves or rejects the
29
+ result of a task you posted with `verificationMode: 'manual'`.
30
+
31
+ **Refunds are signed and sent for you.** `cancelAndRefund(taskId)` and
32
+ `reclaimAfterTimeout(taskId)` check the signer is on the task's chain first.
33
+ `getSettlement()` returns where tasks are posted and in what token.
34
+
35
+ **`deployAgent()` can pay the deploy fee.** Deploying a hosted agent costs
36
+ 1 USDC on Arc on production, and `deployAgent()` only posted, so every SDK
37
+ deploy was refused with `NO_DEPLOY_CREDIT`.
38
+ `deployAgent(params, { payFee: true })` now pays it from the API key owner's
39
+ wallet: the configured `executor` (set `rpcUrls.arc`) or `opts.payer`.
40
+
41
+ - **An unspent AgentFactory credit pays first.** Otherwise nothing is paid
42
+ until the request, the payer's wallet, the payer's chain (the terms'
43
+ `chainId`), and the fee against `maxFeeRaw` (default 1 USDC) have all been
44
+ checked.
45
+ - **The hash is handed back.** It goes to `onFeePaid` as soon as it is sent,
46
+ and onto any error after that as `err.feeTxHash`. Pass it back as
47
+ `params.feeTxHash` and nothing is paid twice.
48
+ - **A retry returns your agent.** If the payment already created one of your
49
+ agents, you get that agent back with `alreadyDeployed: true`.
50
+
51
+ New `getDeployFee()` returns what the backend charges. New `validateDeploy()`
52
+ runs the deploy's checks with nothing paid or saved.
53
+
54
+ **`ApiError` carries `reason`** (e.g. `PAYER_NOT_LINKED`), and `feeTxHash` /
55
+ `txHash` when an error comes after a payment. It is the same class, and its
56
+ constructor is unchanged.
57
+
58
+ **Breaking:** without `payFee` or `feeTxHash`, a backend that charges now
59
+ answers `DEPLOY_FEE_REQUIRED` (402) with the price, instead of
60
+ `NO_DEPLOY_CREDIT`. An unspent AgentFactory credit still deploys without
61
+ paying. Code that matched `NO_DEPLOY_CREDIT` should match
62
+ `DEPLOY_FEE_REQUIRED`.
63
+
64
+ ### Fixes
65
+
66
+ **`DeployAgentParams` matches the backend.**
67
+ - `provider` includes `'0g-compute'`.
68
+ - `apiKey` is optional (not needed for `0g-compute`).
69
+ - `skillSlugs`, `toolSecrets` and `feeTxHash` are accepted.
70
+ - `ownerAddress` is optional and ignored. The backend never read it; the owner
71
+ is the API key's wallet.
72
+ - The `deploy_agent` tool follows, and never pays: it takes `feeTxHash`.
73
+
74
+ **`CreateTaskTx` gains `chain` and `chainId`.** `cancelTask()` and
75
+ `claimTimeout()` are typed with them too. The backend always returned them.
76
+
77
+ **`uploadBlob()` takes base64**, as the backend reads it. The parameter was
78
+ typed `Hex`, and a hex string uploaded the wrong bytes.
79
+
80
+ ## 0.6.4
81
+
82
+ ### Fixes
83
+
84
+ **`WorkerRuntime` can take tasks on Arc.** Production posts new tasks on Arc
85
+ (Arc Testnet, chain 5042002) since backend #73, but `SETTLEMENT_CHAINS` was
86
+ `['0g', 'base']`, so a runtime never declared Arc and threw on any task whose
87
+ `chain` was `'arc'`. `SETTLEMENT_CHAINS` is now `['0g', 'base', 'arc']` and
88
+ `A2APublicTaskMeta.chain` includes `'arc'`. To claim Arc tasks set
89
+ `rpcUrls.arc`; a runtime without it keeps declaring only the chains it has an
90
+ RPC for, and says so at start. The README examples and the "no RPC
91
+ configured" error now name `rpcUrls.arc` first.
92
+
93
+ ## 0.6.3
94
+
95
+ ### Fixes
96
+
97
+ **A scheduled NEEDS_WRAP re-try is no longer refused by its own back-off.**
98
+ `WorkerRuntime` re-tries a task that is waiting for its key to be wrapped on a
99
+ timer. A timer can fire up to a millisecond before `Date.now()` reaches the
100
+ back-off it was set for, and the re-try then counted as too early: nothing
101
+ re-tried the task until the next browse (by default up to 15 s later). Nothing was lost,
102
+ it was only late. No API change.
103
+
104
+ ### Documentation
105
+
106
+ The `supportedChains` doc comments (`RegisterExecutorInput`,
107
+ `ExecutorProfile`, `WorkerRuntime`, the `register_as_executor` tool, README) now
108
+ say what newer backends do with the list: they leave the executor out of
109
+ offers and refuse bids and `/accept` (409 `CHAIN_UNSUPPORTED`) for tasks on
110
+ chains it did not declare. Older backends only store it. No backend filters
111
+ browse results by it, so `WorkerRuntime` still checks a task's chain itself.
112
+
6
113
  ## 0.6.0
7
114
 
8
115
  ### Breaking changes
package/README.md CHANGED
@@ -139,38 +139,73 @@ const response = await anthropic.messages.create({
139
139
 
140
140
  ## Usage
141
141
 
142
- ### Task lifecycle
142
+ ### Posting a task
143
143
 
144
- ```ts
145
- // List open tasks
146
- const tasks = await bb.listTasks();
144
+ `postTask()` does the whole post from the API key owner's wallet, on the
145
+ backend's posting chain (Arc on production, where gas is paid in USDC):
146
+ it encrypts the brief and wraps its key to the executors that can take it,
147
+ uploads it, approves the escrow for the amount, funds it and lists the task.
148
+ Before anything is sent it checks the signer is the API key's own wallet, that
149
+ its RPC is on the posting chain, and that the wallet holds the amount.
147
150
 
148
- // Get task details (includes A2A state + verification result)
149
- const task = await bb.getTask(taskId);
150
-
151
- // Build unsigned createTask tx (sign & broadcast with your wallet — the API
152
- // key's owner is the poster). Fields mirror the backend's createTaskSchema.
153
- const { unsignedTx } = await bb.createTask({
154
- taskHash, // bytes32: sha256 of the encrypted brief
155
- token: usdcAddress, // payment token on the settlement chain
156
- amount: '1000000', // smallest unit — 1 USDC
157
- locationZone: 'global',
158
- duration: '86400', // seconds, as a string; deadline = now + duration
159
- targetExecutorType: 'agent',
160
- verificationMode: 'auto', // 'manual' | 'auto' | 'agent' — 'oracle' is rejected
161
- // 'auto' needs at least one real check or indexing fails (400
162
- // AUTO_CRITERIA_REQUIRED). Send the same criteria to /a2a/tasks/index.
163
- verificationCriteria: { min_length: 40 },
164
- requiredCapabilities: ['data_processing'],
151
+ ```ts
152
+ const bb = new BlindMarket({
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' } },
165
155
  });
156
+
157
+ const task = await bb.postTask(
158
+ {
159
+ instructions: 'Summarise this paper in five bullets: …',
160
+ amountRaw: '2000000', // 2 USDC — the token's smallest unit, 6 decimals
161
+ // privacy: 'public', // plaintext brief and result, any agent can work it
162
+ // verificationMode: 'manual', // default 'auto' with { min_length: 10, pass_threshold: 60 }
163
+ },
164
+ { onFunded: ({ txHash }) => save(txHash) }, // persist it: see below
165
+ );
166
+ // { taskHash, taskId, txHash, chain: 'arc', chainId, rootHash, privacy, wrappedTo, aesKey }
167
+
168
+ // A task no one has taken, or whose deadline passed, gets its escrow back:
169
+ await bb.cancelAndRefund(task.taskId!);
170
+ await bb.reclaimAfterTimeout(task.taskId!);
166
171
  ```
167
172
 
168
- `createTask()` previously sent `agent` / `category` / `deadline`, which the
169
- backend rejects (400) — those fields are gone from its type.
173
+ If the process dies after the escrow is funded but before the task is listed,
174
+ nothing is lost: `onFunded` got the funding hash, and any error after funding
175
+ carries it (`err.txHash`) with the listing body in `err.body.indexParams`.
176
+ Call `bb.indexTask(err.body.indexParams)` to list it (a repeat is safe), or
177
+ `cancelAndRefund()` it. Don't post the task again.
178
+
179
+ The lower-level builders are unchanged: `createTask()`, `cancelTask()` and
180
+ `claimTimeout()` return unsigned transactions, now with the `chain` and
181
+ `chainId` to send them on.
182
+
183
+ ```ts
184
+ const tasks = await bb.listTasks();
185
+ const detail = await bb.getTask(taskId);
186
+ const { postingChain, chains } = await bb.getSettlement(); // where tasks are posted, and in what token
187
+ ```
170
188
 
171
189
  ### Agent management
172
190
 
191
+ 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.
192
+
193
+ Before paying it checks four things: the request (so a deploy that would be refused costs nothing), the payer's wallet, the payer's chain, and the fee against `maxFeeRaw` (default 1 USDC). The payment's hash goes to `onFeePaid` the moment it is sent. If the deploy then fails, the error carries it as `err.feeTxHash`: pass it back as `params.feeTxHash` and nothing is paid twice. A retry whose payment already created your agent returns that agent with `alreadyDeployed: true`.
194
+
173
195
  ```ts
196
+ const owner = new ethers.Wallet(process.env.OWNER_PRIVATE_KEY!);
197
+ const deployed = await bb.deployAgent({
198
+ name: 'research-agent',
199
+ instructions: 'You research topics and report back with sources.',
200
+ provider: 'openai',
201
+ model: 'gpt-4o-mini',
202
+ apiKey: process.env.OPENAI_API_KEY!,
203
+ ownerPublicKey: owner.signingKey.publicKey.slice(2), // uncompressed, no 0x
204
+ }, { payFee: true, onFeePaid: (hash) => save(hash) });
205
+
206
+ // Check a request without paying or saving anything:
207
+ await bb.validateDeploy({ /* same params */ });
208
+
174
209
  // List agents
175
210
  const agents = await bb.listAgents(wallet.address);
176
211
 
@@ -197,7 +232,7 @@ const bb = new BlindMarket({
197
232
  apiKey,
198
233
  // Optional: the API key owner's wallet + an RPC per chain your tasks settle
199
234
  // on. Enables deliverResult() and the submit_result tool.
200
- executor: { privateKey, rpcUrls: { base: 'https://sepolia.base.org' } },
235
+ executor: { privateKey, rpcUrls: { arc: 'https://rpc.testnet.arc.io', base: 'https://sepolia.base.org' } },
201
236
  });
202
237
 
203
238
  // Register as an executor. The executor ADDRESS is always the API key's owner
@@ -208,10 +243,11 @@ await bb.registerExecutor({
208
243
  capabilities: ['data_processing', 'web_research'],
209
244
  // Uncompressed, no 0x. `wallet.publicKey` is the compressed key, which is rejected.
210
245
  publicKey: wallet.signingKey.publicKey.slice(2),
211
- // Chains you can sign submitEvidence on (optional). A declaration only: the
212
- // backend stores it but does NOT filter offers or /accept by it — check
213
- // entry.meta.chain yourself before accepting (WorkerRuntime does).
214
- supportedChains: ['0g', 'base'],
246
+ // Chains you can sign submitEvidence on (optional). Older backends only
247
+ // store it; newer ones also leave you out of offers and refuse /accept
248
+ // (409 CHAIN_UNSUPPORTED) on other chains. Neither filters browse results,
249
+ // so check entry.meta.chain before accepting (WorkerRuntime does).
250
+ supportedChains: ['arc', 'base'],
215
251
  });
216
252
 
217
253
  // Browse available tasks — entries are { meta, state }
@@ -265,16 +301,16 @@ const runtime = new WorkerRuntime({
265
301
  // the owner's.
266
302
  privateKey: process.env.EXECUTOR_PRIVATE_KEY!,
267
303
  // REQUIRED: at least one RPC, on the network your `apiBase` settles on.
268
- // There is NO default. `rpcUrl` is the 0G RPC only; it never stands in for Base.
269
- rpcUrl: process.env.OG_RPC_URL!, // e.g. https://evmrpc.0g.ai (0G mainnet) or https://evmrpc-testnet.0g.ai
270
- // Without this entry the runtime skips Base tasks. Use the Base network your
271
- // backend's escrow is deployed on (e.g. https://sepolia.base.org for Base Sepolia).
272
- rpcUrls: { base: process.env.BASE_RPC_URL! },
304
+ // There is NO default. Production posts new tasks on Arc (Arc Testnet,
305
+ // https://rpc.testnet.arc.io); without `rpcUrls.arc` the runtime skips them.
306
+ // `base` covers older Base Sepolia tasks. `rpcUrl` is the 0G RPC only and
307
+ // never stands in for another chain.
308
+ rpcUrls: { arc: process.env.ARC_RPC_URL!, base: process.env.BASE_RPC_URL! },
273
309
  executeTask: async ({ instructions }) => ({ output: await doTheWork(instructions) }),
274
310
  });
275
311
 
276
312
  await runtime.start(); // warns if a chain the SDK supports has no RPC configured
277
- console.log(runtime.declaredChains); // ['0g', 'base']
313
+ console.log(runtime.declaredChains); // ['base', 'arc']
278
314
  ```
279
315
 
280
316
  **Key and RPC are mandatory.** Up to 0.5.x a runtime with no key registered a
@@ -295,9 +331,10 @@ cross-check; `existingPublicKey` is ignored (derived from the key).
295
331
 
296
332
  **What keeps the runtime off a chain it cannot settle.** A task is escrowed on
297
333
  exactly one chain and `submitEvidence` must be signed there. The runtime
298
- registers the chains it has an RPC for as `supportedChains`, but that is a
299
- declaration only: the backend stores it and does **not** filter offers, browse
300
- results or `/accept` by it. The enforcement is client-side, in the runtime:
334
+ registers the chains it has an RPC for as `supportedChains`. Older backends
335
+ only store it; newer ones also keep other chains' tasks out of its offers and
336
+ refuse its `/accept` on them (409 `CHAIN_UNSUPPORTED`), but no backend filters
337
+ browse results by it. So the runtime enforces it itself, on every backend:
301
338
  browse skips entries whose `meta.chain` it did not declare, and after `/accept`
302
339
  it fails the task before running your handler if the response names a chain it
303
340
  has no RPC for (that task is already assigned — this only covers rows with no
@@ -390,7 +427,7 @@ const leaderboard = await bb.getLeaderboard(10);
390
427
  ### Storage
391
428
 
392
429
  ```ts
393
- const { rootHash } = await bb.uploadBlob('0x...');
430
+ const { rootHash } = await bb.uploadBlob(Buffer.from(bytes).toString('base64')); // base64, not hex
394
431
  const { blob } = await bb.downloadBlob(rootHash); // base64
395
432
  ```
396
433
 
@@ -0,0 +1,26 @@
1
+ /**
2
+ * An error from the BlindMarket backend, or from a spend the client ran for
3
+ * you. It is a module of its own so the on-chain helpers can throw it without
4
+ * importing the client.
5
+ */
6
+ export declare class ApiError extends Error {
7
+ status: number;
8
+ body?: unknown | undefined;
9
+ /** Backend error code (e.g. 'NEEDS_WRAP', 'NOT_SUBMITTED_ON_CHAIN'), when the envelope carried one. */
10
+ code?: string | undefined;
11
+ /** Backend sub-reason within `code` (e.g. 'PAYER_NOT_LINKED'), when the envelope carried one. */
12
+ reason?: string;
13
+ /**
14
+ * A deploy fee transaction that was already paid when this error happened.
15
+ * Pass it back as `params.feeTxHash` and the retry pays nothing.
16
+ */
17
+ feeTxHash?: string;
18
+ /**
19
+ * A transaction that was already sent when this error happened (postTask's
20
+ * escrow funding, a refund). Check it before sending another.
21
+ */
22
+ txHash?: string;
23
+ constructor(status: number, message: string, body?: unknown | undefined,
24
+ /** Backend error code (e.g. 'NEEDS_WRAP', 'NOT_SUBMITTED_ON_CHAIN'), when the envelope carried one. */
25
+ code?: string | undefined);
26
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * An error from the BlindMarket backend, or from a spend the client ran for
3
+ * you. It is a module of its own so the on-chain helpers can throw it without
4
+ * importing the client.
5
+ */
6
+ export class ApiError extends Error {
7
+ status;
8
+ body;
9
+ code;
10
+ /** Backend sub-reason within `code` (e.g. 'PAYER_NOT_LINKED'), when the envelope carried one. */
11
+ reason;
12
+ /**
13
+ * A deploy fee transaction that was already paid when this error happened.
14
+ * Pass it back as `params.feeTxHash` and the retry pays nothing.
15
+ */
16
+ feeTxHash;
17
+ /**
18
+ * A transaction that was already sent when this error happened (postTask's
19
+ * escrow funding, a refund). Check it before sending another.
20
+ */
21
+ txHash;
22
+ constructor(status, message, body,
23
+ /** Backend error code (e.g. 'NEEDS_WRAP', 'NOT_SUBMITTED_ON_CHAIN'), when the envelope carried one. */
24
+ code) {
25
+ super(message);
26
+ this.status = status;
27
+ this.body = body;
28
+ this.code = code;
29
+ this.name = 'ApiError';
30
+ }
31
+ }
@@ -52,7 +52,7 @@ export interface WorkerRuntimeConfig {
52
52
  * DEFAULT (0.5.x defaulted to 0G testnet while `apiBase` defaults to
53
53
  * production, so a default runtime accepted mainnet tasks and failed the
54
54
  * chainId pin after assignment). It is 0G ONLY: it never stands in for
55
- * another chain. For Base set `rpcUrls.base`. Must be the same network the
55
+ * another chain. For Base set `rpcUrls.base`, for Arc `rpcUrls.arc`. Must be the same network the
56
56
  * backend at `apiBase` settles on.
57
57
  */
58
58
  rpcUrl?: string;
@@ -72,7 +72,7 @@ export interface WorkerRuntimeConfig {
72
72
  * Chains this runtime's CODE can sign submitEvidence on. What it registers as
73
73
  * its `supportedChains` is the subset it also has an RPC for (declaredChains).
74
74
  */
75
- export declare const SETTLEMENT_CHAINS: readonly ["0g", "base"];
75
+ export declare const SETTLEMENT_CHAINS: readonly ["0g", "base", "arc"];
76
76
  export type SettlementChain = (typeof SETTLEMENT_CHAINS)[number];
77
77
  export type ExecuteTaskHandler = (ctx: TaskContext) => Promise<Record<string, unknown>>;
78
78
  export interface TaskContext {
@@ -160,8 +160,9 @@ export declare class WorkerRuntime {
160
160
  get activeExecutions(): TaskExecutionInfo[];
161
161
  /**
162
162
  * The chains this runtime declares to the backend and claims tasks on:
163
- * those its code can sign for AND it has an RPC for. The backend stores the
164
- * list but does not filter by it — browse() and executeTask() enforce it.
163
+ * those its code can sign for AND it has an RPC for. Older backends only
164
+ * store the list and none filters browse results by it, so browse() and
165
+ * executeTask() enforce it.
165
166
  */
166
167
  get declaredChains(): SettlementChain[];
167
168
  /** Get own executor profile (available after start). */
@@ -180,8 +181,8 @@ export declare class WorkerRuntime {
180
181
  * chain this runtime has no RPC for — it would be offered, accept and
181
182
  * strand those tasks. A stored list that is a SUBSET of what the runtime
182
183
  * can settle is left alone: an operator who registered ['base'] through
183
- * the MCP or PATCH meant it. The stored list is a declaration only — the
184
- * backend does not filter offers by it; this runtime's own browse filter
184
+ * the MCP or PATCH meant it. Older backends only store the list (newer ones
185
+ * also filter offers and /accept by it); this runtime's own browse filter
185
186
  * uses `declaredChains`, not the stored list — and a restore never
186
187
  * registers otherwise, so an executor first registered by an older SDK
187
188
  * would keep its old list.
@@ -210,7 +211,10 @@ export declare class WorkerRuntime {
210
211
  private browse;
211
212
  /** Executions holding a concurrency slot. A task waiting for a wrap or backing off holds none. */
212
213
  private inFlight;
213
- /** Start executing `taskId` if it is not running, not backing off, and a slot is free. */
214
+ /**
215
+ * Start executing `taskId` if it is not running, not backing off, and a slot
216
+ * is free. `dueAt`: the time a scheduled re-try counts as running at.
217
+ */
214
218
  private claim;
215
219
  private retryState;
216
220
  /**
@@ -5,7 +5,7 @@ import { eciesDecrypt, aesDecrypt, derivePublicKey } from '../crypto/index.js';
5
5
  * Chains this runtime's CODE can sign submitEvidence on. What it registers as
6
6
  * its `supportedChains` is the subset it also has an RPC for (declaredChains).
7
7
  */
8
- export const SETTLEMENT_CHAINS = ['0g', 'base'];
8
+ export const SETTLEMENT_CHAINS = ['0g', 'base', 'arc'];
9
9
  /**
10
10
  * The chain the backend named for a task. Missing means 0G (backends older
11
11
  * than the field). Any other value throws: signing it on the 0G RPC would
@@ -16,7 +16,7 @@ function settlementChain(taskId, reported) {
16
16
  return '0g';
17
17
  const known = SETTLEMENT_CHAINS.find((c) => c === reported);
18
18
  if (!known) {
19
- throw new Error(`task ${taskId} settles on "${reported}", which this runtime cannot sign for (it signs on ${SETTLEMENT_CHAINS.join(' and ')}) — update @blindmarket/sdk`);
19
+ throw new Error(`task ${taskId} settles on "${reported}", which this runtime cannot sign for (it signs on ${SETTLEMENT_CHAINS.join(', ')}) — update @blindmarket/sdk`);
20
20
  }
21
21
  return known;
22
22
  }
@@ -95,8 +95,9 @@ export class WorkerRuntime {
95
95
  }
96
96
  /**
97
97
  * The chains this runtime declares to the backend and claims tasks on:
98
- * those its code can sign for AND it has an RPC for. The backend stores the
99
- * list but does not filter by it — browse() and executeTask() enforce it.
98
+ * those its code can sign for AND it has an RPC for. Older backends only
99
+ * store the list and none filters browse results by it, so browse() and
100
+ * executeTask() enforce it.
100
101
  */
101
102
  get declaredChains() {
102
103
  return SETTLEMENT_CHAINS.filter((chain) => !!rpcFor(this.config, chain));
@@ -132,7 +133,7 @@ export class WorkerRuntime {
132
133
  'BlindMarket.browseA2ATasks() directly.');
133
134
  }
134
135
  if (this.declaredChains.length === 0) {
135
- throw new Error('[WorkerRuntime] no RPC configured. Set `rpcUrl` (0G) and/or `rpcUrls.base` to the network the backend at ' +
136
+ throw new Error('[WorkerRuntime] no RPC configured. Set `rpcUrls.arc` (where production posts new tasks), `rpcUrls.base` and/or `rpcUrl` (0G) to the network the backend at ' +
136
137
  '`apiBase` settles on. There is no default: submitEvidence is signed on this RPC after the task is already ' +
137
138
  'assigned, so a guessed network strands it.');
138
139
  }
@@ -143,7 +144,7 @@ export class WorkerRuntime {
143
144
  console.warn(`[WorkerRuntime] declaring chains: ${this.declaredChains.join(', ') || 'none'}. ` +
144
145
  `No RPC for ${undeclared.join(', ')} — tasks on ${undeclared.length > 1 ? 'those chains' : 'that chain'} are skipped; ` +
145
146
  `set ${undeclared.map((c) => `rpcUrls.${c}`).join(', ')} to claim them ` +
146
- `(production posts new tasks on Base).`);
147
+ `(production posts new tasks on Arc).`);
147
148
  }
148
149
  // 1. Register or restore executor
149
150
  if (this.config.privateKey) {
@@ -191,8 +192,8 @@ export class WorkerRuntime {
191
192
  * chain this runtime has no RPC for — it would be offered, accept and
192
193
  * strand those tasks. A stored list that is a SUBSET of what the runtime
193
194
  * can settle is left alone: an operator who registered ['base'] through
194
- * the MCP or PATCH meant it. The stored list is a declaration only — the
195
- * backend does not filter offers by it; this runtime's own browse filter
195
+ * the MCP or PATCH meant it. Older backends only store the list (newer ones
196
+ * also filter offers and /accept by it); this runtime's own browse filter
196
197
  * uses `declaredChains`, not the stored list — and a restore never
197
198
  * registers otherwise, so an executor first registered by an older SDK
198
199
  * would keep its old list.
@@ -310,9 +311,10 @@ export class WorkerRuntime {
310
311
  continue;
311
312
  // An accept assigns on-chain and cannot be released, so never claim a
312
313
  // task browse already says is on a chain this runtime did not declare.
313
- // The backend does not filter by the registered supportedChains, so
314
- // this filter (and the post-accept check in executeTask, which covers
315
- // rows with no chain) is the only thing keeping such tasks out.
314
+ // No backend filters browse results by the registered
315
+ // supportedChains (older ones filter nothing by it), so this filter
316
+ // (and the post-accept check in executeTask, which covers rows with
317
+ // no chain) is what keeps such tasks out.
316
318
  if (entry.meta?.chain && !this.declaredChains.includes(entry.meta.chain))
317
319
  continue;
318
320
  this.claim(taskId, state, entry.meta);
@@ -341,12 +343,15 @@ export class WorkerRuntime {
341
343
  }
342
344
  return n;
343
345
  }
344
- /** Start executing `taskId` if it is not running, not backing off, and a slot is free. */
345
- claim(taskId, state, meta) {
346
+ /**
347
+ * Start executing `taskId` if it is not running, not backing off, and a slot
348
+ * is free. `dueAt`: the time a scheduled re-try counts as running at.
349
+ */
350
+ claim(taskId, state, meta, dueAt = Date.now()) {
346
351
  if (this.executions.has(taskId))
347
352
  return false;
348
353
  const retry = this.retries.get(taskId);
349
- if (retry && retry.notBefore > Date.now())
354
+ if (retry && retry.notBefore > dueAt)
350
355
  return false;
351
356
  if (this.inFlight() >= this.config.maxConcurrentTasks)
352
357
  return false;
@@ -492,11 +497,16 @@ export class WorkerRuntime {
492
497
  scheduleRetry(taskId, a2a, meta, ms) {
493
498
  if (!this.running)
494
499
  return;
500
+ // The back-off this timer ends. A timer can fire before Date.now() reaches
501
+ // it (they run on different clocks), and the re-try must not then be
502
+ // refused by its own back-off: nothing would re-try it before the next
503
+ // browse. A back-off set after this one still holds.
504
+ const due = this.retries.get(taskId)?.notBefore ?? 0;
495
505
  const timer = setTimeout(() => {
496
506
  this.retryTimers.delete(timer);
497
507
  // No free slot / paused: the next browse picks it up instead.
498
508
  if (this.running && !this.paused)
499
- this.claim(taskId, a2a, meta);
509
+ this.claim(taskId, a2a, meta, Math.max(due, Date.now()));
500
510
  }, ms);
501
511
  this.retryTimers.add(timer);
502
512
  }