@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 +30 -0
- package/README.md +32 -2
- package/dist/chain/abi/BlindEscrow.json +154 -0
- package/dist/escrowCalls.d.ts +51 -0
- package/dist/escrowCalls.js +95 -0
- package/dist/executor/WorkerRuntime.d.ts +20 -0
- package/dist/executor/WorkerRuntime.js +71 -0
- package/dist/index.d.ts +54 -5
- package/dist/index.js +131 -28
- package/dist/tools/helpers.js +1 -1
- package/dist/types.d.ts +13 -0
- package/package.json +1 -1
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
|
-
/**
|
|
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
|
|
363
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
166
|
-
*
|
|
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
|
|
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
|
-
|
|
270
|
-
|
|
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,
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
888
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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. */
|
package/dist/tools/helpers.js
CHANGED
|
@@ -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
|
|
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). */
|