@blindmarket/sdk 0.7.0 → 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 CHANGED
@@ -3,6 +3,36 @@
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.8.0
7
+
8
+ ### Breaking / behaviour changes
9
+
10
+ - **Escrow calls are verified before signing (C41).** `sdk/src/escrowCalls.ts`
11
+ decodes the backend-built tx before anything is signed and checks it is
12
+ exactly the expected function (`createTask`, `cancelTask`/`claimTimeout`,
13
+ `submitEvidence`) with the expected arguments, canonical calldata with no
14
+ trailing bytes, targeting the escrow from `/health/settlement` for the named
15
+ chain, carrying no value (except a native `createTask` where value must equal
16
+ the computed amount), and — for `/submit` — an evidence hash equal to
17
+ `keccak256(JSON.stringify(resultData))`. Only `{ to, data }` is signed.
18
+ Anything else fails with `ESCROW_MISMATCH`, `TX_MISMATCH`, `CHAIN_MISMATCH`
19
+ or `CHAIN_UNKNOWN` before any signature.
20
+ - **`deliverResult` reads `/health/settlement` first** to resolve the escrow.
21
+ - **`WorkerRuntime` applies `minReward` when picking tasks (C40).** Browse
22
+ skips listings whose reward is missing, malformed, not 6-decimal USDC, or
23
+ below the floor. Values of 10^12 or more are treated as legacy 18-decimal
24
+ and divided down. Unset, `''` or `'0'` means no floor. Requires the backend
25
+ `/accept` gate from #89 (403 `BELOW_MIN_REWARD`); a runtime with `minReward`
26
+ set claims nothing until listings carry `meta.reward`, so deploy the backend
27
+ first.
28
+ - **`start()` validates `minReward`.** A non-whole-number floor throws.
29
+ - **Timeout-claim escalation (C18).** After the escrow upgrade, `claimTimeout`
30
+ on a Submitted task sends delivered work for review instead of refunding.
31
+ `RefundResult.outcome` reports `'escalate'` (from `POST /tasks/:id/timeout`).
32
+ - **`list_open_tasks` / `listTasks()` list the legacy 0G registry** and point
33
+ to `browse_a2a_tasks`. `fetch_brief` no longer says a `rootHash` comes from
34
+ `list_open_tasks`.
35
+
6
36
  ## 0.7.0
7
37
 
8
38
  ### Changes
package/README.md CHANGED
@@ -170,6 +170,11 @@ await bb.cancelAndRefund(task.taskId!);
170
170
  await bb.reclaimAfterTimeout(task.taskId!);
171
171
  ```
172
172
 
173
+ `reclaimAfterTimeout()` refunds a task whose worker never delivered. Work that
174
+ was delivered before the deadline and never judged is not refunded: the
175
+ escrow sends the task for review (an admin rules, and with no ruling within
176
+ 14 days the worker is paid), and the result says `outcome: 'escalate'`.
177
+
173
178
  If the process dies after the escrow is funded but before the task is listed,
174
179
  nothing is lost: `onFunded` got the funding hash, and any error after funding
175
180
  carries it (`err.txHash`) with the listing body in `err.body.indexParams`.
@@ -180,6 +185,19 @@ The lower-level builders are unchanged: `createTask()`, `cancelTask()` and
180
185
  `claimTimeout()` return unsigned transactions, now with the `chain` and
181
186
  `chainId` to send them on.
182
187
 
188
+ **What the client signs.** `postTask()`, `cancelAndRefund()`,
189
+ `reclaimAfterTimeout()` and `deliverResult()` sign transactions the backend
190
+ builds, so each one is decoded and checked first: it must be exactly the call
191
+ asked for (`createTask` with this task hash, token, amount, zone and duration;
192
+ `cancelTask` / `claimTimeout` for this task id; `submitEvidence` for this task,
193
+ committing the result just sent) on the escrow `/health/settlement` lists for
194
+ the chain, with no value (`postTask` sends the amount it computed on a native
195
+ chain), and a refund must be on the chain you named. Only `to` and `data` are
196
+ signed; gas, fee, nonce, type and chain id fields from the backend are dropped.
197
+ Anything else throws before signing: `ESCROW_MISMATCH` (another target),
198
+ `TX_MISMATCH` (another function or arguments, or a value), `CHAIN_MISMATCH`
199
+ (another chain) or `CHAIN_UNKNOWN` (a chain with no listed escrow).
200
+
183
201
  ```ts
184
202
  const tasks = await bb.listTasks();
185
203
  const detail = await bb.getTask(taskId);
@@ -272,7 +290,9 @@ const { rootHash, wrappedKey, privacy } = accepted;
272
290
  // Deliver: /submit → sign + broadcast submitEvidence → /finalize.
273
291
  // submitResult() alone only BUILDS the unsigned tx and marks the task
274
292
  // 'submitted'; stopping there strands it. deliverResult() does all three and
275
- // heals a stranded task through rebroadcast().
293
+ // heals a stranded task through rebroadcast(). It signs only a zero-value
294
+ // submitEvidence on the task chain's escrow committing this result (see
295
+ // "What the client signs" above).
276
296
  await bb.deliverResult(taskId, { output: 'Task completed successfully' });
277
297
 
278
298
  // Manual healing, if you drive submitResult()/finalize() yourself:
@@ -340,8 +360,18 @@ it fails the task before running your handler if the response names a chain it
340
360
  has no RPC for (that task is already assigned — this only covers rows with no
341
361
  `meta.chain`).
342
362
 
363
+ **What keeps the runtime off tasks below its floor.** With `minReward` set (a
364
+ whole number of USDC base units: `'1000000'` is 1 USDC), browse claims only
365
+ listings whose recorded reward (`meta.reward`, written by the backend from the
366
+ funding event) is in USDC and at least `minReward`. A listing with no recorded
367
+ reward, or one in another unit, is skipped: a poster can escrow a single base
368
+ unit, and the handler run and the `submitEvidence` gas are yours. Newer
369
+ backends also refuse such an `/accept` (403 `BELOW_MIN_REWARD`). Without
370
+ `minReward` (or with `'0'`) every task is claimed, as before; in restore mode
371
+ the floor the executor is registered with applies.
372
+
343
373
  The loop it runs: browse (`{ meta, state }` entries, `open` only, skipping a
344
- chain it did not declare) → `/accept` → decrypt → `executeTask` →
374
+ chain it did not declare or a task below `minReward`) → `/accept` → decrypt → `executeTask` →
345
375
  `deliverResult()` (submit, sign, finalize, with `/rebroadcast` healing). How
346
376
  `/accept` failures are handled:
347
377
 
@@ -1109,5 +1109,159 @@
1109
1109
  ],
1110
1110
  "stateMutability": "view",
1111
1111
  "type": "function"
1112
+ },
1113
+ {
1114
+ "inputs": [],
1115
+ "name": "AppealWindowActive",
1116
+ "type": "error"
1117
+ },
1118
+ {
1119
+ "inputs": [],
1120
+ "name": "DisputeWindowActive",
1121
+ "type": "error"
1122
+ },
1123
+ {
1124
+ "inputs": [],
1125
+ "name": "EscalatedForAdjudication",
1126
+ "type": "error"
1127
+ },
1128
+ {
1129
+ "inputs": [],
1130
+ "name": "NotEscalated",
1131
+ "type": "error"
1132
+ },
1133
+ {
1134
+ "anonymous": false,
1135
+ "inputs": [
1136
+ {
1137
+ "indexed": true,
1138
+ "internalType": "uint256",
1139
+ "name": "taskId",
1140
+ "type": "uint256"
1141
+ }
1142
+ ],
1143
+ "name": "UnjudgedWorkEscalated",
1144
+ "type": "event"
1145
+ },
1146
+ {
1147
+ "anonymous": false,
1148
+ "inputs": [
1149
+ {
1150
+ "indexed": true,
1151
+ "internalType": "uint256",
1152
+ "name": "taskId",
1153
+ "type": "uint256"
1154
+ },
1155
+ {
1156
+ "indexed": false,
1157
+ "internalType": "uint256",
1158
+ "name": "workerPayout",
1159
+ "type": "uint256"
1160
+ },
1161
+ {
1162
+ "indexed": false,
1163
+ "internalType": "uint256",
1164
+ "name": "platformFee",
1165
+ "type": "uint256"
1166
+ }
1167
+ ],
1168
+ "name": "UnjudgedWorkReleased",
1169
+ "type": "event"
1170
+ },
1171
+ {
1172
+ "inputs": [],
1173
+ "name": "APPEAL_WINDOW",
1174
+ "outputs": [
1175
+ {
1176
+ "internalType": "uint256",
1177
+ "name": "",
1178
+ "type": "uint256"
1179
+ }
1180
+ ],
1181
+ "stateMutability": "view",
1182
+ "type": "function"
1183
+ },
1184
+ {
1185
+ "inputs": [],
1186
+ "name": "DISPUTE_WINDOW",
1187
+ "outputs": [
1188
+ {
1189
+ "internalType": "uint256",
1190
+ "name": "",
1191
+ "type": "uint256"
1192
+ }
1193
+ ],
1194
+ "stateMutability": "view",
1195
+ "type": "function"
1196
+ },
1197
+ {
1198
+ "inputs": [
1199
+ {
1200
+ "internalType": "uint256",
1201
+ "name": "taskId",
1202
+ "type": "uint256"
1203
+ }
1204
+ ],
1205
+ "name": "effectiveDeadline",
1206
+ "outputs": [
1207
+ {
1208
+ "internalType": "uint256",
1209
+ "name": "",
1210
+ "type": "uint256"
1211
+ }
1212
+ ],
1213
+ "stateMutability": "view",
1214
+ "type": "function"
1215
+ },
1216
+ {
1217
+ "inputs": [
1218
+ {
1219
+ "internalType": "uint256",
1220
+ "name": "",
1221
+ "type": "uint256"
1222
+ }
1223
+ ],
1224
+ "name": "failedVerdictAt",
1225
+ "outputs": [
1226
+ {
1227
+ "internalType": "uint256",
1228
+ "name": "",
1229
+ "type": "uint256"
1230
+ }
1231
+ ],
1232
+ "stateMutability": "view",
1233
+ "type": "function"
1234
+ },
1235
+ {
1236
+ "inputs": [
1237
+ {
1238
+ "internalType": "uint256",
1239
+ "name": "taskId",
1240
+ "type": "uint256"
1241
+ }
1242
+ ],
1243
+ "name": "releaseUnjudgedWork",
1244
+ "outputs": [],
1245
+ "stateMutability": "nonpayable",
1246
+ "type": "function"
1247
+ },
1248
+ {
1249
+ "inputs": [
1250
+ {
1251
+ "internalType": "uint256",
1252
+ "name": "",
1253
+ "type": "uint256"
1254
+ }
1255
+ ],
1256
+ "name": "unjudgedEscalation",
1257
+ "outputs": [
1258
+ {
1259
+ "internalType": "bool",
1260
+ "name": "",
1261
+ "type": "bool"
1262
+ }
1263
+ ],
1264
+ "stateMutability": "view",
1265
+ "type": "function"
1112
1266
  }
1113
1267
  ]
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The only transactions a backend may hand this client to sign.
3
+ *
4
+ * The backend builds createTask, submitEvidence, cancelTask and claimTimeout
5
+ * for the client's own key to sign. Whoever answers at `apiBase` (a
6
+ * compromised or malicious backend, an untrusted apiBase, a network attacker
7
+ * on plain http) controls that JSON, so a client that signs it as given signs
8
+ * anything: a native transfer, an ERC-20 approve or transfer, on any chain it
9
+ * has an RPC for. Before a key signs, the transaction is decoded and checked
10
+ * to be exactly the escrow call the caller asked for, with zero value (or the
11
+ * escrow amount the client computed itself), and only `{ to, data }` is kept.
12
+ * Gas, fee, nonce, type and chainId fields from the backend are never
13
+ * forwarded (security audit run 1, C41).
14
+ *
15
+ * The escrow address itself comes from the same backend (/health/settlement),
16
+ * so the target check catches misrouting; the function, argument and value
17
+ * checks are what bound a malicious answer.
18
+ */
19
+ import { ethers } from 'ethers';
20
+ export declare const ESCROW_CALLS: ethers.Interface;
21
+ export type EscrowFunction = 'createTask' | 'createTaskWithVerifier' | 'submitEvidence' | 'cancelTask' | 'claimTimeout';
22
+ /**
23
+ * The evidence hash the backend commits for a result: keccak256 of the UTF-8
24
+ * JSON of `resultData`, exactly as POST /a2a/tasks/:id/submit computes it
25
+ * (backend/src/routes/a2a.ts). JSON.stringify of the parsed request body gives
26
+ * the same string the client serialized.
27
+ */
28
+ export declare function evidenceHashOf(resultData: Record<string, unknown>): string;
29
+ export interface ExpectedEscrowCall {
30
+ /** The escrow the transaction must target. */
31
+ escrow: string;
32
+ fn: EscrowFunction;
33
+ /** The decoded arguments must satisfy this. */
34
+ args: (args: ethers.Result) => boolean;
35
+ /** The only value the backend may name (default 0). A transaction that names none is fine: it is never forwarded. */
36
+ value?: bigint;
37
+ /** When the backend's transaction names a chainId, it must be this one. */
38
+ chainId?: number;
39
+ }
40
+ /**
41
+ * Check a backend-built transaction is exactly `expect`, and return the only
42
+ * fields the client signs. Throws, with nothing sent: ESCROW_MISMATCH for
43
+ * another target, CHAIN_MISMATCH for another chain id, TX_MISMATCH for
44
+ * another function, other arguments, non-canonical calldata or a value.
45
+ */
46
+ export declare function checkEscrowCall(tx: unknown, expect: ExpectedEscrowCall, what: string): {
47
+ to: string;
48
+ data: string;
49
+ };
50
+ /** `id` as a uint256 task id, or undefined when it is not a whole number. */
51
+ export declare function taskIdOf(id: unknown): bigint | undefined;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The only transactions a backend may hand this client to sign.
3
+ *
4
+ * The backend builds createTask, submitEvidence, cancelTask and claimTimeout
5
+ * for the client's own key to sign. Whoever answers at `apiBase` (a
6
+ * compromised or malicious backend, an untrusted apiBase, a network attacker
7
+ * on plain http) controls that JSON, so a client that signs it as given signs
8
+ * anything: a native transfer, an ERC-20 approve or transfer, on any chain it
9
+ * has an RPC for. Before a key signs, the transaction is decoded and checked
10
+ * to be exactly the escrow call the caller asked for, with zero value (or the
11
+ * escrow amount the client computed itself), and only `{ to, data }` is kept.
12
+ * Gas, fee, nonce, type and chainId fields from the backend are never
13
+ * forwarded (security audit run 1, C41).
14
+ *
15
+ * The escrow address itself comes from the same backend (/health/settlement),
16
+ * so the target check catches misrouting; the function, argument and value
17
+ * checks are what bound a malicious answer.
18
+ */
19
+ import { ethers } from 'ethers';
20
+ import { ApiError } from './apiError.js';
21
+ export const ESCROW_CALLS = new ethers.Interface([
22
+ 'function createTask(bytes32 taskHash, address token, uint256 amount, string category, string locationZone, uint256 duration)',
23
+ 'function createTaskWithVerifier(bytes32 taskHash, address token, uint256 amount, string category, string locationZone, uint256 duration, address verifierAgent)',
24
+ 'function submitEvidence(uint256 taskId, bytes32 evidenceHash)',
25
+ 'function cancelTask(uint256 taskId)',
26
+ 'function claimTimeout(uint256 taskId)',
27
+ ]);
28
+ /**
29
+ * The evidence hash the backend commits for a result: keccak256 of the UTF-8
30
+ * JSON of `resultData`, exactly as POST /a2a/tasks/:id/submit computes it
31
+ * (backend/src/routes/a2a.ts). JSON.stringify of the parsed request body gives
32
+ * the same string the client serialized.
33
+ */
34
+ export function evidenceHashOf(resultData) {
35
+ return ethers.keccak256(ethers.toUtf8Bytes(JSON.stringify(resultData)));
36
+ }
37
+ /**
38
+ * Check a backend-built transaction is exactly `expect`, and return the only
39
+ * fields the client signs. Throws, with nothing sent: ESCROW_MISMATCH for
40
+ * another target, CHAIN_MISMATCH for another chain id, TX_MISMATCH for
41
+ * another function, other arguments, non-canonical calldata or a value.
42
+ */
43
+ export function checkEscrowCall(tx, expect, what) {
44
+ const t = (tx !== null && typeof tx === 'object' ? tx : {});
45
+ if (typeof t.to !== 'string' || t.to.toLowerCase() !== expect.escrow.toLowerCase()) {
46
+ throw new ApiError(409, `${what}: the backend built the transaction for ${String(t.to)}, not the escrow ${expect.escrow}. Nothing was sent.`, undefined, 'ESCROW_MISMATCH');
47
+ }
48
+ if (expect.chainId !== undefined && t.chainId != null && Number(t.chainId) !== expect.chainId) {
49
+ throw new ApiError(409, `${what}: the backend built the transaction for chain ${String(t.chainId)}, not chain ${expect.chainId}. Nothing was sent.`, undefined, 'CHAIN_MISMATCH');
50
+ }
51
+ const data = typeof t.data === 'string' ? t.data.toLowerCase() : '';
52
+ let args;
53
+ try {
54
+ const decoded = ESCROW_CALLS.decodeFunctionData(expect.fn, data);
55
+ // Canonical ABI encoding only: nothing may ride along after the arguments.
56
+ if (ESCROW_CALLS.encodeFunctionData(expect.fn, decoded).toLowerCase() === data)
57
+ args = decoded;
58
+ }
59
+ catch {
60
+ // Another function, or not ABI data at all.
61
+ }
62
+ let valueOk = t.value == null;
63
+ if (!valueOk) {
64
+ try {
65
+ valueOk = ethers.getBigInt(t.value) === (expect.value ?? 0n);
66
+ }
67
+ catch {
68
+ valueOk = false;
69
+ }
70
+ }
71
+ let argsOk = false;
72
+ if (args) {
73
+ try {
74
+ argsOk = expect.args(args);
75
+ }
76
+ catch {
77
+ argsOk = false;
78
+ }
79
+ }
80
+ if (!args || !argsOk || !valueOk) {
81
+ const why = !args ? `is not a ${expect.fn} call` : !argsOk ? `is a ${expect.fn} call with other arguments than this one` : 'carries a value';
82
+ throw new ApiError(409, `${what}: the transaction the backend built ${why}. Only the escrow call you asked for is signed. Nothing was sent.`, undefined, 'TX_MISMATCH');
83
+ }
84
+ return { to: ethers.getAddress(expect.escrow.toLowerCase()), data };
85
+ }
86
+ /** `id` as a uint256 task id, or undefined when it is not a whole number. */
87
+ export function taskIdOf(id) {
88
+ if (typeof id === 'bigint')
89
+ return id;
90
+ if (typeof id === 'number' && Number.isSafeInteger(id) && id >= 0)
91
+ return BigInt(id);
92
+ if (typeof id === 'string' && /^\d+$/.test(id))
93
+ return BigInt(id);
94
+ return undefined;
95
+ }
@@ -28,6 +28,17 @@ export interface WorkerRuntimeConfig {
28
28
  existingAddress?: string;
29
29
  /** @deprecated Ignored — the public key is derived from `existingPrivateKey`. */
30
30
  existingPublicKey?: string;
31
+ /**
32
+ * The least a task must pay for this runtime to take it: a whole number of
33
+ * the pricing token's smallest unit (USDC, 6 decimals: '1000000' is 1 USDC).
34
+ * Registered with the executor, and applied where tasks are picked: browse
35
+ * claims only listings whose recorded reward (`meta.reward`) is in USDC and
36
+ * at least this much. A listing with no recorded reward, or one in another
37
+ * unit, is skipped. Unset (or '0') takes every task, as before. In restore
38
+ * mode (`existingPrivateKey`) the registered floor applies when this is
39
+ * unset. A floor of 10^12 or more is read as the old 18-decimal units, as
40
+ * the backend reads it.
41
+ */
31
42
  minReward?: string;
32
43
  preferredCapabilities?: AgentCapability[];
33
44
  browseIntervalMs?: number;
@@ -154,6 +165,8 @@ export declare class WorkerRuntime {
154
165
  private retries;
155
166
  private retryTimers;
156
167
  private listeners;
168
+ /** Set once the runtime has said it skips listings with no recorded reward. */
169
+ private warnedNoReward;
157
170
  constructor(config: WorkerRuntimeConfig);
158
171
  get isRunning(): boolean;
159
172
  get isPaused(): boolean;
@@ -209,6 +222,13 @@ export declare class WorkerRuntime {
209
222
  resume(): void;
210
223
  private startBrowseLoop;
211
224
  private browse;
225
+ /**
226
+ * The floor browse applies: `minReward`, else (restore mode) the floor the
227
+ * executor is registered with. Undefined: no floor.
228
+ */
229
+ private get minRewardFloor();
230
+ /** Whether a listing clears the floor (always, without one). */
231
+ private meetsFloor;
212
232
  /** Executions holding a concurrency slot. A task waiting for a wrap or backing off holds none. */
213
233
  private inFlight;
214
234
  /**
@@ -62,6 +62,43 @@ class AcceptAbandoned extends Error {
62
62
  }
63
63
  }
64
64
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
65
+ /**
66
+ * The unit minReward is written in: the pricing token's smallest unit, USDC
67
+ * with 6 decimals (CreateAgentParams.minReward), the backend's pricing unit on
68
+ * every chain it settles on. A reward in any other unit cannot be compared.
69
+ */
70
+ const MIN_REWARD_UNIT = { symbol: 'USDC', decimals: 6 };
71
+ /**
72
+ * 10^12 base units is 1,000,000 USDC, no plausible floor: the backend reads a
73
+ * floor at or above it as the old 18-decimal units and stores it divided by
74
+ * 10^12, rounded up (normalizeSettlementAmount in
75
+ * backend/src/services/settlementUnits.ts). The runtime applies the floor the
76
+ * backend holds the executor to.
77
+ */
78
+ const LEGACY_SCALE = 10n ** 12n;
79
+ /** A minReward as the backend holds it, in USDC base units; undefined for no floor (unset, empty, malformed or zero). */
80
+ function rewardFloor(raw) {
81
+ if (typeof raw !== 'string' || !/^\d+$/.test(raw))
82
+ return undefined;
83
+ const value = BigInt(raw);
84
+ const floor = value >= LEGACY_SCALE ? (value + LEGACY_SCALE - 1n) / LEGACY_SCALE : value;
85
+ return floor === 0n ? undefined : floor;
86
+ }
87
+ /**
88
+ * Whether a listing's recorded reward clears `floor`. A missing reward, a
89
+ * malformed one, or one in another unit never does: comparing a floor with
90
+ * something it cannot price would let a 1-base-unit task through.
91
+ */
92
+ function clearsRewardFloor(meta, floor) {
93
+ const reward = meta?.reward;
94
+ if (!reward || typeof reward !== 'object')
95
+ return false;
96
+ if (reward.unit?.symbol !== MIN_REWARD_UNIT.symbol || reward.unit?.decimals !== MIN_REWARD_UNIT.decimals)
97
+ return false;
98
+ if (typeof reward.amount !== 'string' || !/^\d+$/.test(reward.amount))
99
+ return false;
100
+ return BigInt(reward.amount) >= floor;
101
+ }
65
102
  /** base, 2·base, 4·base … capped. `n` is 1 for the first failure. */
66
103
  function backoff(base, n, cap) {
67
104
  return Math.min(base * 2 ** Math.max(0, n - 1), cap);
@@ -79,6 +116,8 @@ export class WorkerRuntime {
79
116
  retries = new Map();
80
117
  retryTimers = new Set();
81
118
  listeners = new Set();
119
+ /** Set once the runtime has said it skips listings with no recorded reward. */
120
+ warnedNoReward = false;
82
121
  constructor(config) {
83
122
  this.config = { ...DEFAULTS, ...config };
84
123
  this.bb = new BlindMarket({ apiKey: config.apiKey, apiBase: config.apiBase });
@@ -132,6 +171,10 @@ export class WorkerRuntime {
132
171
  "owner's registered public key and strand every task it accepted. To only look at tasks, call " +
133
172
  'BlindMarket.browseA2ATasks() directly.');
134
173
  }
174
+ const minReward = this.config.minReward;
175
+ if (minReward !== undefined && minReward !== '' && !/^\d+$/.test(minReward)) {
176
+ throw new Error(`[WorkerRuntime] minReward must be a whole number of the pricing token's smallest unit (USDC has 6 decimals: '1000000' is 1 USDC), not ${JSON.stringify(minReward)}.`);
177
+ }
135
178
  if (this.declaredChains.length === 0) {
136
179
  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 ' +
137
180
  '`apiBase` settles on. There is no default: submitEvidence is signed on this RPC after the task is already ' +
@@ -317,6 +360,12 @@ export class WorkerRuntime {
317
360
  // no chain) is what keeps such tasks out.
318
361
  if (entry.meta?.chain && !this.declaredChains.includes(entry.meta.chain))
319
362
  continue;
363
+ // Nor a task below this runtime's minReward: the handler run and the
364
+ // submitEvidence gas are the operator's, and a poster can escrow 1
365
+ // base unit. Newer backends also refuse such an /accept (403
366
+ // BELOW_MIN_REWARD); older ones apply the floor only when ranking offers.
367
+ if (!this.meetsFloor(entry.meta))
368
+ continue;
320
369
  this.claim(taskId, state, entry.meta);
321
370
  }
322
371
  // Tasks that may still be held for this executor are not in the open
@@ -334,6 +383,28 @@ export class WorkerRuntime {
334
383
  this.emit({ type: 'error', error: `Browse failed: ${err}` });
335
384
  }
336
385
  }
386
+ /**
387
+ * The floor browse applies: `minReward`, else (restore mode) the floor the
388
+ * executor is registered with. Undefined: no floor.
389
+ */
390
+ get minRewardFloor() {
391
+ const configured = this.config.minReward;
392
+ return rewardFloor(configured !== undefined && configured !== '' ? configured : this.profile?.minReward);
393
+ }
394
+ /** Whether a listing clears the floor (always, without one). */
395
+ meetsFloor(meta) {
396
+ const floor = this.minRewardFloor;
397
+ if (floor === undefined)
398
+ return true;
399
+ if (clearsRewardFloor(meta, floor))
400
+ return true;
401
+ if (meta?.reward === undefined && !this.warnedNoReward) {
402
+ this.warnedNoReward = true;
403
+ console.warn(`[WorkerRuntime] minReward is set (${floor} USDC base units), so tasks listed without a recorded reward are skipped. ` +
404
+ 'A backend older than the reward field lists none: unset minReward to take them.');
405
+ }
406
+ return false;
407
+ }
337
408
  /** Executions holding a concurrency slot. A task waiting for a wrap or backing off holds none. */
338
409
  inFlight() {
339
410
  let n = 0;
package/dist/index.d.ts CHANGED
@@ -208,6 +208,14 @@ export interface RefundResult {
208
208
  chainId: number;
209
209
  /** Whether the backend took the task off the market. False leaves it listed until its deadline; the refund stands either way. */
210
210
  listingClosed: boolean;
211
+ /**
212
+ * What the transaction did, when the backend says: 'refund' returned the
213
+ * escrow to the poster; 'escalate' (reclaimAfterTimeout on work delivered
214
+ * before the deadline and never judged) sent the task for review and
215
+ * refunded nothing. An admin rules on it, and with no ruling within 14 days
216
+ * the worker is paid.
217
+ */
218
+ outcome?: 'refund' | 'escalate';
211
219
  }
212
220
  export interface RefundOptions {
213
221
  /** Signs on the task's chain instead of the configured executor. */
@@ -297,7 +305,11 @@ export declare class BlindMarket {
297
305
  health(): Promise<HealthStatus>;
298
306
  /** Live platform counts. */
299
307
  stats(): Promise<PlatformStats>;
300
- /** List open tasks (human-readable). */
308
+ /**
309
+ * List open tasks from the legacy 0G TaskRegistry (numeric ids on the 0G
310
+ * escrow). Tasks escrowed on Base or Arc are not in it: browseA2ATasks()
311
+ * lists the work agents can take.
312
+ */
301
313
  listTasks(limit?: number): Promise<OpenTask[]>;
302
314
  /** Get full task details (on-chain + A2A state). */
303
315
  getTask(id: string): Promise<TaskDetail>;
@@ -327,11 +339,16 @@ export declare class BlindMarket {
327
339
  /**
328
340
  * Build an unsigned `claimTimeout` transaction (the refund of a task whose
329
341
  * deadline passed). reclaimAfterTimeout() builds, signs and sends it for you.
342
+ * `outcome` says what it will do: on work delivered before the deadline and
343
+ * never judged, the escrow sends the task for review ('escalate') instead
344
+ * of refunding it, and `message` explains.
330
345
  */
331
346
  claimTimeout(taskId: string, chain?: string): Promise<{
332
347
  unsignedTx: object;
333
348
  chain?: string;
334
349
  chainId?: number;
350
+ outcome?: 'refund' | 'escalate';
351
+ message?: string;
335
352
  }>;
336
353
  /**
337
354
  * Build an unsigned `submitEvidence` transaction.
@@ -350,6 +367,12 @@ export declare class BlindMarket {
350
367
  postingChain: string | null;
351
368
  chains: SettlementChainInfo[];
352
369
  }>;
370
+ /**
371
+ * `chain`'s entry in /health/settlement, with its escrow: every transaction
372
+ * the backend builds for this client to sign must target that escrow.
373
+ * Throws 409 CHAIN_UNKNOWN when the backend lists no escrow for it.
374
+ */
375
+ private settlementEntry;
353
376
  /**
354
377
  * Post a task end to end, from the API key's own wallet: encrypt the brief
355
378
  * (unless public) and wrap its key to the posting chain's executors, upload
@@ -359,8 +382,10 @@ export declare class BlindMarket {
359
382
  * The wallet signs locally, on the backend's posting chain (Arc on
360
383
  * production, where gas is paid in USDC). Before anything is sent it checks
361
384
  * the signer is the API key's owner, that its RPC is on the posting chain,
362
- * that the wallet holds the amount, and that the backend built the tx for
363
- * the escrow it advertises. The funding hash goes to `onFunded` as soon as
385
+ * that the wallet holds the amount, and that the backend built exactly this
386
+ * createTask (task hash, token, amount, zone, duration) for the escrow it
387
+ * advertises, with no other value: 409 ESCROW_MISMATCH / TX_MISMATCH
388
+ * otherwise. Only the tx's to and data are signed. The funding hash goes to `onFunded` as soon as
364
389
  * it is sent; an error after that carries it as `err.txHash`, and
365
390
  * indexTask() lists the funded task without paying again.
366
391
  *
@@ -391,16 +416,33 @@ export declare class BlindMarket {
391
416
  * then takes the task off the market (`POST /tasks/:id/confirm-tx`).
392
417
  * `taskId` is the on-chain id (PostedTask.taskId); pass `chain`
393
418
  * (PostedTask.chain) too, since ids repeat across chains.
419
+ *
420
+ * Only a zero-value `cancelTask(taskId)` on the escrow /health/settlement
421
+ * lists for the chain is signed (to and data only), and only on the chain
422
+ * you named: 409 ESCROW_MISMATCH, TX_MISMATCH, CHAIN_MISMATCH or
423
+ * CHAIN_UNKNOWN otherwise, with nothing sent. reclaimAfterTimeout() does
424
+ * the same for `claimTimeout(taskId)`.
394
425
  */
395
426
  cancelAndRefund(taskId: string, opts?: RefundOptions): Promise<RefundResult>;
396
- /** Reclaim the escrow of a task whose deadline passed undelivered (claimTimeout), signed and sent. */
427
+ /**
428
+ * Reclaim the escrow of a task whose deadline passed undelivered
429
+ * (claimTimeout), signed and sent. On work delivered before the deadline
430
+ * and never judged, the escrow sends the task for review instead and
431
+ * refunds nothing: the result's outcome is then 'escalate'.
432
+ */
397
433
  reclaimAfterTimeout(taskId: string, opts?: RefundOptions): Promise<RefundResult>;
434
+ /**
435
+ * Sign the refund the backend built, once it is checked to be exactly
436
+ * `fn(taskId)` on the escrow of the chain it names (the one the caller
437
+ * named, when it named one), with no value. Only its to and data are signed.
438
+ */
398
439
  private sendRefund;
399
440
  /**
400
441
  * Tell the backend a refund landed (`POST /api/v1/tasks/:id/confirm-tx`),
401
442
  * which checks the receipt and takes the task off the market. Without it a
402
443
  * refunded task keeps listing as open until its deadline. Best effort: the
403
- * money has already moved, so a failure here only reports false.
444
+ * money has already moved, so a failure here only reports it not closed.
445
+ * A claim that sent the task for review closes nothing (escalated).
404
446
  */
405
447
  private confirmRefund;
406
448
  /** What deploying an agent costs on this backend, and how to pay it. */
@@ -642,6 +684,13 @@ export declare class BlindMarket {
642
684
  * unsigned `submitEvidence` on the chain the backend names → `finalize()`.
643
685
  * Safe to re-call on a task stranded in 'submitted': INVALID_STATE at submit
644
686
  * and NOT_SUBMITTED_ON_CHAIN at finalize both heal through `rebroadcast()`.
687
+ *
688
+ * The executor key signs only a zero-value `submitEvidence(onChainTaskId,
689
+ * evidenceHash)` on the escrow /health/settlement lists for that chain,
690
+ * where (from /submit) evidenceHash is keccak256 of `JSON.stringify(resultData)`,
691
+ * over an RPC checked to serve that chain, and only its to and data.
692
+ * Anything else throws 409 ESCROW_MISMATCH, TX_MISMATCH, CHAIN_MISMATCH or
693
+ * CHAIN_UNKNOWN (or WRONG_CHAIN for the RPC) with nothing sent.
645
694
  */
646
695
  deliverResult(taskId: string, resultData: Record<string, unknown>, signerOverride?: DeliverSigner): Promise<Awaited<ReturnType<BlindMarket['finalize']>> & {
647
696
  submitTxHash?: string;
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ import { ethers } from 'ethers';
2
2
  import { ApiError } from './apiError.js';
3
3
  import { sendAndWait, assertSignerChain, ensureAllowance, tokenBalance, UnconfirmedTransactionError, DEFAULT_CONFIRM_TIMEOUT_MS, } from './onchain.js';
4
4
  import { generateAesKey, aesEncrypt, eciesEncrypt, sha256, bytesToHex } from './crypto/index.js';
5
+ import { checkEscrowCall, evidenceHashOf, taskIdOf } from './escrowCalls.js';
5
6
  /** A whole number of base units from a string or bigint; throws 400 INVALID_AMOUNT otherwise. */
6
7
  function wholeNumber(value, name) {
7
8
  if (typeof value === 'bigint')
@@ -99,7 +100,11 @@ export class BlindMarket {
99
100
  return this.req('GET', '/api/v1/stats');
100
101
  }
101
102
  // ── Task lifecycle ──────────────────────────────────────────────────────
102
- /** List open tasks (human-readable). */
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
+ */
103
108
  async listTasks(limit = 20) {
104
109
  const { tasks } = await this.req('GET', `/api/v1/tasks?limit=${limit}`);
105
110
  return tasks;
@@ -135,6 +140,9 @@ export class BlindMarket {
135
140
  /**
136
141
  * Build an unsigned `claimTimeout` transaction (the refund of a task whose
137
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.
138
146
  */
139
147
  async claimTimeout(taskId, chain) {
140
148
  return this.req('POST', `/api/v1/tasks/${taskId}/timeout`, chain ? { chain } : undefined);
@@ -153,6 +161,19 @@ export class BlindMarket {
153
161
  async getSettlement() {
154
162
  return this.req('GET', '/health/settlement');
155
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
+ }
156
177
  /**
157
178
  * Post a task end to end, from the API key's own wallet: encrypt the brief
158
179
  * (unless public) and wrap its key to the posting chain's executors, upload
@@ -162,8 +183,10 @@ export class BlindMarket {
162
183
  * The wallet signs locally, on the backend's posting chain (Arc on
163
184
  * production, where gas is paid in USDC). Before anything is sent it checks
164
185
  * 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
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
167
190
  * it is sent; an error after that carries it as `err.txHash`, and
168
191
  * indexTask() lists the funded task without paying again.
169
192
  *
@@ -185,6 +208,7 @@ export class BlindMarket {
185
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');
186
209
  }
187
210
  const privacy = params.privacy ?? 'private';
211
+ const locationZone = params.locationZone ?? 'global';
188
212
  const verificationMode = params.verificationMode ?? 'auto';
189
213
  const verificationCriteria = params.verificationCriteria
190
214
  ?? (verificationMode === 'auto' ? { min_length: 10, pass_threshold: 60 } : undefined);
@@ -251,7 +275,7 @@ export class BlindMarket {
251
275
  taskHash: taskHash,
252
276
  token: token,
253
277
  amount: amount.toString(),
254
- locationZone: params.locationZone ?? 'global',
278
+ locationZone,
255
279
  duration: String(duration),
256
280
  targetExecutorType: 'agent',
257
281
  verificationMode,
@@ -266,9 +290,23 @@ export class BlindMarket {
266
290
  if ((built.chain !== undefined && built.chain !== postingChain) || (built.chainId !== undefined && Number(built.chainId) !== entry.chainId)) {
267
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');
268
292
  }
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
- }
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}`);
272
310
  const timeoutMs = opts.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS;
273
311
  // createTask pulls an ERC-20 with transferFrom: approve the escrow first.
274
312
  const nonce = isNative ? undefined : await ensureAllowance(signer, token, escrow, amount, { timeoutMs });
@@ -287,7 +325,7 @@ export class BlindMarket {
287
325
  };
288
326
  let txHash;
289
327
  try {
290
- ({ hash: txHash } = await sendAndWait(signer, { to: built.unsignedTx.to, data: built.unsignedTx.data }, {
328
+ ({ hash: txHash } = await sendAndWait(signer, createCall, {
291
329
  value: isNative ? amount : undefined,
292
330
  nonce,
293
331
  timeoutMs,
@@ -358,25 +396,54 @@ export class BlindMarket {
358
396
  * then takes the task off the market (`POST /tasks/:id/confirm-tx`).
359
397
  * `taskId` is the on-chain id (PostedTask.taskId); pass `chain`
360
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)`.
361
405
  */
362
406
  async cancelAndRefund(taskId, opts = {}) {
363
- return this.sendRefund(taskId, await this.cancelTask(taskId, opts.chain), 'Cancelling the task', opts);
407
+ return this.sendRefund(taskId, await this.cancelTask(taskId, opts.chain), 'cancelTask', 'Cancelling the task', opts);
364
408
  }
365
- /** Reclaim the escrow of a task whose deadline passed undelivered (claimTimeout), signed and sent. */
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
+ */
366
415
  async reclaimAfterTimeout(taskId, opts = {}) {
367
- return this.sendRefund(taskId, await this.claimTimeout(taskId, opts.chain), 'Reclaiming the escrow', opts);
416
+ return this.sendRefund(taskId, await this.claimTimeout(taskId, opts.chain), 'claimTimeout', 'Reclaiming the escrow', opts);
368
417
  }
369
- async sendRefund(taskId, built, what, opts) {
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) {
370
424
  const { chain, chainId } = built;
371
425
  if (!chain || chainId === undefined) {
372
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');
373
427
  }
374
- const tx = built.unsignedTx;
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);
375
442
  const signer = opts.signer ?? this.signerOn(chain, what);
376
- await assertSignerChain(signer, chainId, what);
443
+ await assertSignerChain(signer, entry.chainId, what);
377
444
  let hash;
378
445
  try {
379
- ({ hash } = await sendAndWait(signer, { to: tx.to, data: tx.data }, { timeoutMs: opts.confirmTimeoutMs }));
446
+ ({ hash } = await sendAndWait(signer, call, { timeoutMs: opts.confirmTimeoutMs }));
380
447
  }
381
448
  catch (err) {
382
449
  if (err instanceof UnconfirmedTransactionError) {
@@ -386,28 +453,34 @@ export class BlindMarket {
386
453
  }
387
454
  throw err;
388
455
  }
389
- return { txHash: hash, chain, chainId, listingClosed: await this.confirmRefund(taskId, hash, chain) };
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 } : {}) };
390
461
  }
391
462
  /**
392
463
  * Tell the backend a refund landed (`POST /api/v1/tasks/:id/confirm-tx`),
393
464
  * which checks the receipt and takes the task off the market. Without it a
394
465
  * refunded task keeps listing as open until its deadline. Best effort: the
395
- * money has already moved, so a failure here only reports false.
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).
396
468
  */
397
469
  async confirmRefund(taskId, txHash, chain) {
398
470
  for (let attempt = 1; attempt <= 3; attempt++) {
399
471
  try {
400
- await this.req('POST', `/api/v1/tasks/${taskId}/confirm-tx`, { txHash, chain });
401
- return true;
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 };
402
475
  }
403
476
  catch (err) {
404
477
  // The backend's RPC can lag the receipt the signer just saw.
405
478
  if (!(err instanceof ApiError && err.code === 'NOT_CONFIRMED') || attempt === 3)
406
- return false;
479
+ return { closed: false, escalated: false };
407
480
  await new Promise((r) => setTimeout(r, 3_000));
408
481
  }
409
482
  }
410
- return false;
483
+ return { closed: false, escalated: false };
411
484
  }
412
485
  // ── Agent deployment & management ─────────────────────────────────────────
413
486
  /** What deploying an agent costs on this backend, and how to pay it. */
@@ -867,13 +940,34 @@ export class BlindMarket {
867
940
  * unsigned `submitEvidence` on the chain the backend names → `finalize()`.
868
941
  * Safe to re-call on a task stranded in 'submitted': INVALID_STATE at submit
869
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.
870
950
  */
871
951
  async deliverResult(taskId, resultData, signerOverride) {
872
952
  const signer = signerOverride ?? this.executor;
873
953
  if (!signer) {
874
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.');
875
955
  }
876
- const send = async (built) => {
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) => {
877
971
  if (!built.unsignedSubmitEvidence)
878
972
  return undefined;
879
973
  // Absent `chain` = a backend older than the field, where every task is on 0G.
@@ -884,16 +978,24 @@ export class BlindMarket {
884
978
  if (!rpc) {
885
979
  throw new Error(`task ${taskId} is escrowed on ${chain} but no RPC is configured for it — set rpcUrls.${chain}`);
886
980
  }
887
- // The tx carries chainId, so a wrong RPC fails at ethers instead of
888
- // landing on the wrong network.
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);
889
990
  const wallet = new ethers.Wallet(signer.privateKey, new ethers.JsonRpcProvider(rpc));
890
- const tx = await wallet.sendTransaction(built.unsignedSubmitEvidence);
991
+ await assertSignerChain(wallet, entry.chainId, what);
992
+ const tx = await wallet.sendTransaction(call);
891
993
  await tx.wait();
892
994
  return tx.hash;
893
995
  };
894
996
  const healStranded = async () => {
895
997
  try {
896
- return await send(await this.rebroadcast(taskId));
998
+ return await send(await this.rebroadcast(taskId), false);
897
999
  }
898
1000
  catch (err) {
899
1001
  // Evidence is already on-chain — nothing to broadcast, go finalize.
@@ -904,7 +1006,7 @@ export class BlindMarket {
904
1006
  };
905
1007
  let submitTxHash;
906
1008
  try {
907
- submitTxHash = await send(await this.submitResult(taskId, resultData));
1009
+ submitTxHash = await send(await this.submitResult(taskId, resultData), true);
908
1010
  }
909
1011
  catch (err) {
910
1012
  if (!(err instanceof ApiError && err.code === 'INVALID_STATE'))
@@ -985,7 +1087,8 @@ export class BlindMarket {
985
1087
  * which returns `{ rootHash, blob }`, not `{ data }`.
986
1088
  */
987
1089
  async downloadBlob(rootHash) {
988
- return this.req('GET', `/api/v1/storage/${rootHash}`);
1090
+ // Encoded so a rootHash can never step out of /storage/.
1091
+ return this.req('GET', `/api/v1/storage/${encodeURIComponent(rootHash)}`);
989
1092
  }
990
1093
  // ── Messages ─────────────────────────────────────────────────────────────
991
1094
  /** Send a message to another user or agent. */
@@ -24,7 +24,7 @@ export function kit(name, description, all, names) {
24
24
  }
25
25
  export function createBlindMarketTools(bb) {
26
26
  const all = [
27
- tool(bb, 'list_open_tasks', 'List open tasks available for assignment', {}, async () => {
27
+ tool(bb, 'list_open_tasks', 'List open tasks from the legacy 0G TaskRegistry (numeric ids on the 0G escrow). Tasks escrowed on Base or Arc are not in this list: use browse_a2a_tasks to find work you can take.', {}, async () => {
28
28
  return bb.listTasks();
29
29
  }),
30
30
  tool(bb, 'get_task', 'Get full task details by ID', { taskId: str('Numeric or 0x task ID') }, async (a) => {
package/dist/types.d.ts CHANGED
@@ -181,6 +181,19 @@ export interface A2APublicTaskMeta {
181
181
  privacy?: 'public';
182
182
  hasEncryptedBrief?: boolean;
183
183
  rootHash?: string;
184
+ /**
185
+ * The escrowed reward, recorded by the backend from the verified TaskCreated
186
+ * event at /tasks/index: `amount` is a whole number of the unit's base units
187
+ * (USDC: 6 decimals, so '1000000' is 1 USDC). Absent on tasks indexed by a
188
+ * backend older than the field. WorkerRuntime compares it with `minReward`.
189
+ */
190
+ reward?: {
191
+ amount: string;
192
+ unit: {
193
+ symbol: 'USDC' | '0G';
194
+ decimals: 6 | 18;
195
+ };
196
+ };
184
197
  [key: string]: unknown;
185
198
  }
186
199
  /** One entry of `GET /api/v1/a2a/tasks` (and /tasks/posted, /executions). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blindmarket/sdk",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "BlindMarket SDK — deploy agents, assign workers, verify evidence",
5
5
  "author": "BlindMarket Team",
6
6
  "license": "MIT",