brainchain-worker 0.1.0 → 0.2.1
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/README.md +28 -12
- package/dist/abi.js +71 -90
- package/dist/chain.js +202 -65
- package/dist/index.js +170 -84
- package/dist/network.js +18 -2
- package/dist/state.js +77 -0
- package/dist/work.js +214 -68
- package/package.json +1 -1
package/dist/chain.js
CHANGED
|
@@ -1,19 +1,28 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { BaseError, ContractFunctionRevertedError, createPublicClient, createWalletClient, encodeFunctionData, http, HttpRequestError, keccak256, parseAbi, TimeoutError, toBytes, } from "viem";
|
|
3
|
+
import { erc20Errors, taskBoardAbi, workerStakingAbi } from "./abi.js";
|
|
3
4
|
import { chainOf } from "./network.js";
|
|
5
|
+
import { memoryStore } from "./state.js";
|
|
4
6
|
export const erc20Abi = parseAbi([
|
|
5
7
|
"function balanceOf(address) view returns (uint256)",
|
|
6
8
|
"function allowance(address owner, address spender) view returns (uint256)",
|
|
7
9
|
"function approve(address spender, uint256 amount) returns (bool)",
|
|
8
10
|
"function faucet()",
|
|
11
|
+
"function FAUCET_AMOUNT() view returns (uint256)",
|
|
12
|
+
"function FAUCET_INTERVAL() view returns (uint256)",
|
|
13
|
+
"function lastClaim(address) view returns (uint256)",
|
|
9
14
|
"error TooSoon(uint256 nextClaimAt)",
|
|
10
15
|
]);
|
|
11
|
-
export const bondsAbi =
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
export const bondsAbi = [
|
|
17
|
+
...parseAbi([
|
|
18
|
+
"event Proposed(uint256 indexed id, address indexed proposer, address indexed target, bytes data, uint128 bond, string uri)",
|
|
19
|
+
"event Settled(uint256 indexed id, uint256 indexed decisionId, bool adopted)",
|
|
20
|
+
"function propose(address target, bytes data, string uri) returns (uint256)",
|
|
21
|
+
"function get(uint256 id) view returns ((address proposer, uint128 bond, uint64 createdAt, uint8 state, bytes32 subject))",
|
|
22
|
+
"function bond() view returns (uint128)",
|
|
23
|
+
]),
|
|
24
|
+
...erc20Errors,
|
|
25
|
+
];
|
|
17
26
|
export const executorAbi = parseAbi([
|
|
18
27
|
"event Queued(uint256 indexed changeId, uint256 indexed decisionId, address indexed target, bytes data, uint64 eta)",
|
|
19
28
|
"function getChange(uint256 changeId) view returns ((address target, bytes data, uint64 eta, bool executed, bool vetoed))",
|
|
@@ -23,9 +32,15 @@ export const Status = { Open: 1, Hired: 2, Submitted: 3, Graded: 4, Disputed: 5,
|
|
|
23
32
|
/** Mirrors ProposalBonds.State. */
|
|
24
33
|
export const ApplicationState = { Open: 1, Returned: 2, Burned: 3 };
|
|
25
34
|
const LOG_RANGE = 50000n;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
35
|
+
const MAX_BRIEF_BYTES = 256 * 1024;
|
|
36
|
+
/** What the service answers when it will never take this result. */
|
|
37
|
+
const REFUSED = [400, 403, 409, 413];
|
|
38
|
+
/** A failure that trying again cannot fix. */
|
|
39
|
+
export class PermanentError extends Error {
|
|
40
|
+
}
|
|
41
|
+
/** What the worker signs to upload a result: the chain, the board, the task and the hash of exactly the bytes it uploads. */
|
|
42
|
+
export function resultMessage(chainId, board, task, hash) {
|
|
43
|
+
return ["BrainChain result v2", `chain ${chainId}`, `board ${board.toLowerCase()}`, `task ${task}`, `hash ${hash}`].join("\n");
|
|
29
44
|
}
|
|
30
45
|
/** WorkerStaking.idOf: a worker's id is its account, left-padded to 32 bytes. */
|
|
31
46
|
export function workerId(account) {
|
|
@@ -34,7 +49,67 @@ export function workerId(account) {
|
|
|
34
49
|
export function applicationData(account) {
|
|
35
50
|
return encodeFunctionData({ abi: taskBoardAbi, functionName: "setWorkerAllowed", args: [account, true] });
|
|
36
51
|
}
|
|
37
|
-
|
|
52
|
+
/** The contract error a call reverted with, when the ABI knows it. */
|
|
53
|
+
function revertOf(error) {
|
|
54
|
+
const revert = error instanceof BaseError ? error.walk((e) => e instanceof ContractFunctionRevertedError) : null;
|
|
55
|
+
return revert instanceof ContractFunctionRevertedError ? revert.data : undefined;
|
|
56
|
+
}
|
|
57
|
+
export function submitFailure(error) {
|
|
58
|
+
const name = revertOf(error)?.errorName;
|
|
59
|
+
if (name === "TooLate")
|
|
60
|
+
return new PermanentError("The deadline passed before the result could be submitted");
|
|
61
|
+
if (name === "BadStatus")
|
|
62
|
+
return new PermanentError("The task no longer waits for a result");
|
|
63
|
+
return error;
|
|
64
|
+
}
|
|
65
|
+
/** When the faucet opens again for this wallet, if it refused a claim as too soon. */
|
|
66
|
+
export function faucetReopens(error) {
|
|
67
|
+
const revert = revertOf(error);
|
|
68
|
+
return revert?.errorName === "TooSoon" ? revert.args?.[0] : undefined;
|
|
69
|
+
}
|
|
70
|
+
const hostOf = (url) => (URL.canParse(url) ? new URL(url).host : "the RPC");
|
|
71
|
+
/** viem passes on an answer that is not JSON as a quoted JSON string. */
|
|
72
|
+
function unquote(text) {
|
|
73
|
+
if (!text.startsWith('"'))
|
|
74
|
+
return text;
|
|
75
|
+
try {
|
|
76
|
+
const value = JSON.parse(text);
|
|
77
|
+
return typeof value === "string" ? value : text;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return text;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** A URL that is not an RPC, such as a web page, answers with a whole page; one short line of it is enough. */
|
|
84
|
+
function excerpt(details) {
|
|
85
|
+
const line = unquote(details)
|
|
86
|
+
.replace(/\s+/g, " ")
|
|
87
|
+
.replace(/[\x00-\x1f\x7f-\x9f]/g, "")
|
|
88
|
+
.trim();
|
|
89
|
+
return line.length > 160 ? `${line.slice(0, 159)}…` : line;
|
|
90
|
+
}
|
|
91
|
+
/** One readable line for an error. It names the RPC by host only: its URL can hold an API key. */
|
|
92
|
+
export function reason(error) {
|
|
93
|
+
if (!(error instanceof BaseError))
|
|
94
|
+
return error instanceof Error ? error.message : String(error);
|
|
95
|
+
const request = error.walk((e) => e instanceof HttpRequestError || e instanceof TimeoutError);
|
|
96
|
+
if (request instanceof HttpRequestError || request instanceof TimeoutError) {
|
|
97
|
+
const details = request.details ? excerpt(request.details) : "";
|
|
98
|
+
return `Could not reach ${hostOf(request.url)}${details ? ` (${details})` : ""}`;
|
|
99
|
+
}
|
|
100
|
+
const name = revertOf(error)?.errorName;
|
|
101
|
+
return name ? `${error.shortMessage} (${name})` : error.shortMessage;
|
|
102
|
+
}
|
|
103
|
+
/** Why the BrainChain service failed its health check, or undefined when it passed. */
|
|
104
|
+
export async function unhealthy(service, fetcher = fetch) {
|
|
105
|
+
const host = new URL(service).host;
|
|
106
|
+
const response = await fetcher(`${service}/health`, { signal: AbortSignal.timeout(10_000) }).catch(() => undefined);
|
|
107
|
+
if (response === undefined)
|
|
108
|
+
return `could not reach the BrainChain service at ${host}`;
|
|
109
|
+
await response.body?.cancel();
|
|
110
|
+
return response.ok ? undefined : `the BrainChain service at ${host} answered its health check with ${response.status}`;
|
|
111
|
+
}
|
|
112
|
+
export function createChain(network, account, store = memoryStore()) {
|
|
38
113
|
const chain = chainOf(network);
|
|
39
114
|
const transport = http(network.rpc, { timeout: 20_000, retryCount: 3 });
|
|
40
115
|
// BrainChain mines a block per transaction, so receipts are polled every second.
|
|
@@ -43,6 +118,7 @@ export function createChain(network, account) {
|
|
|
43
118
|
const { taskBoard, staking, bonds, executor, stakeToken, bountyToken } = network.contracts;
|
|
44
119
|
const id = workerId(account.address);
|
|
45
120
|
const board = { address: taskBoard, abi: taskBoardAbi };
|
|
121
|
+
const origin = new URL(network.service).origin;
|
|
46
122
|
async function send(request) {
|
|
47
123
|
const hash = await wallet.writeContract(request);
|
|
48
124
|
const receipt = await client.waitForTransactionReceipt({ hash, timeout: 90_000 });
|
|
@@ -50,29 +126,27 @@ export function createChain(network, account) {
|
|
|
50
126
|
throw new Error(`Transaction ${hash} reverted`);
|
|
51
127
|
return hash;
|
|
52
128
|
}
|
|
53
|
-
/** Every log of one event
|
|
54
|
-
async function scan(fetch,
|
|
55
|
-
const latest = upTo ?? (await client.getBlockNumber());
|
|
129
|
+
/** Every log of one event from `from` to `to`, in windows the RPC accepts. */
|
|
130
|
+
async function scan(fetch, from, to) {
|
|
56
131
|
const all = [];
|
|
57
|
-
for (let
|
|
58
|
-
const
|
|
59
|
-
all.push(...(await fetch(
|
|
132
|
+
for (let start = from; start <= to; start += LOG_RANGE) {
|
|
133
|
+
const end = start + LOG_RANGE - 1n < to ? start + LOG_RANGE - 1n : to;
|
|
134
|
+
all.push(...(await fetch(start, end)));
|
|
60
135
|
}
|
|
61
136
|
return all;
|
|
62
137
|
}
|
|
63
|
-
const
|
|
138
|
+
const hiredLogs = (from, to) => scan((fromBlock, toBlock) => client.getContractEvents({ ...board, eventName: "WorkerHired", args: { model: id }, fromBlock, toBlock }), from, to);
|
|
64
139
|
const briefs = new Map();
|
|
65
|
-
async function briefURI(task) {
|
|
140
|
+
async function briefURI(task, latest) {
|
|
66
141
|
const known = briefs.get(task);
|
|
67
142
|
if (known !== undefined)
|
|
68
143
|
return known;
|
|
69
|
-
const [posted] = await scan((fromBlock, toBlock) => client.getContractEvents({ ...board, eventName: "Posted", args: { id: task }, fromBlock, toBlock }));
|
|
144
|
+
const [posted] = await scan((fromBlock, toBlock) => client.getContractEvents({ ...board, eventName: "Posted", args: { id: task }, fromBlock, toBlock }), network.fromBlock, latest);
|
|
70
145
|
if (posted === undefined)
|
|
71
146
|
throw new Error(`Task ${task} has no Posted event`);
|
|
72
147
|
briefs.set(task, posted.args.briefURI);
|
|
73
148
|
return posted.args.briefURI;
|
|
74
149
|
}
|
|
75
|
-
const settled = new Set();
|
|
76
150
|
const work = {
|
|
77
151
|
async now() {
|
|
78
152
|
const block = await client.getBlock();
|
|
@@ -81,50 +155,62 @@ export function createChain(network, account) {
|
|
|
81
155
|
return block.timestamp > wall ? block.timestamp : wall;
|
|
82
156
|
},
|
|
83
157
|
async hired() {
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
|
|
158
|
+
const latest = await client.getBlockNumber();
|
|
159
|
+
const known = store.state.hired;
|
|
160
|
+
const ids = new Set(known?.tasks);
|
|
161
|
+
for (const log of await hiredLogs(known ? BigInt(known.block) + 1n : network.fromBlock, latest))
|
|
162
|
+
ids.add(log.args.id.toString());
|
|
163
|
+
const tasks = await Promise.all([...ids].map(async (key) => {
|
|
164
|
+
const task = BigInt(key);
|
|
87
165
|
const t = await client.readContract({ ...board, functionName: "get", args: [task] });
|
|
88
|
-
if (t.status
|
|
89
|
-
|
|
90
|
-
settled.add(task);
|
|
91
|
-
continue;
|
|
166
|
+
if (t.status === Status.Hired && t.model.toLowerCase() === id) {
|
|
167
|
+
return { id: task, deadline: t.deadline, briefURI: await briefURI(task, latest), briefHash: t.briefHash };
|
|
92
168
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
169
|
+
// A task never returns to Hired, the only state that waits on the worker.
|
|
170
|
+
briefs.delete(task);
|
|
171
|
+
return undefined;
|
|
172
|
+
}));
|
|
173
|
+
const hired = tasks.filter((t) => t !== undefined);
|
|
174
|
+
store.state.hired = { block: Number(latest), tasks: hired.map((t) => t.id.toString()) };
|
|
175
|
+
store.save();
|
|
176
|
+
return hired;
|
|
96
177
|
},
|
|
178
|
+
ethBalance: () => client.getBalance({ address: account.address }),
|
|
97
179
|
async publish(task, text) {
|
|
98
180
|
const hash = keccak256(toBytes(text));
|
|
99
|
-
const signature = await account.signMessage({ message: resultMessage(task, hash) });
|
|
181
|
+
const signature = await account.signMessage({ message: resultMessage(network.chainId, taskBoard, task, hash) });
|
|
100
182
|
const response = await fetch(`${network.service}/results`, {
|
|
101
183
|
method: "POST",
|
|
102
184
|
headers: { "content-type": "application/json" },
|
|
103
185
|
body: JSON.stringify({ task: task.toString(), text, signature }),
|
|
104
186
|
signal: AbortSignal.timeout(30_000),
|
|
187
|
+
}).catch(() => {
|
|
188
|
+
throw new Error(`Could not reach the BrainChain service at ${new URL(origin).host}`);
|
|
105
189
|
});
|
|
106
190
|
const body = (await response.json().catch(() => ({})));
|
|
191
|
+
const why = `(${response.status}): ${body.error ?? "no reason given"}`;
|
|
192
|
+
if (REFUSED.includes(response.status))
|
|
193
|
+
throw new PermanentError(`The service refused the result ${why}`);
|
|
107
194
|
if (!response.ok || !body.uri)
|
|
108
|
-
throw new Error(`Uploading the result failed
|
|
109
|
-
if (body.hash?.toLowerCase() !== hash
|
|
195
|
+
throw new Error(`Uploading the result failed ${why}`);
|
|
196
|
+
if (body.hash?.toLowerCase() !== hash)
|
|
110
197
|
throw new Error("The service stored different bytes than were sent");
|
|
198
|
+
if (!body.uri.startsWith(`${origin}/`))
|
|
199
|
+
throw new Error(`The service returned a result address outside ${origin}`);
|
|
111
200
|
return { uri: body.uri, hash };
|
|
112
201
|
},
|
|
113
|
-
submit: (task, resultHash, resultURI) => send({ ...board, functionName: "submit", args: [task, resultHash, resultURI] })
|
|
202
|
+
submit: (task, resultHash, resultURI) => send({ ...board, functionName: "submit", args: [task, resultHash, resultURI] }).catch((error) => {
|
|
203
|
+
throw submitFailure(error);
|
|
204
|
+
}),
|
|
114
205
|
};
|
|
115
206
|
return {
|
|
116
207
|
...work,
|
|
117
208
|
account: account.address,
|
|
118
|
-
id,
|
|
119
|
-
client,
|
|
120
|
-
send,
|
|
121
|
-
ethBalance: () => client.getBalance({ address: account.address }),
|
|
122
|
-
balance: (token) => client.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
|
|
123
209
|
stakeBalance: () => client.readContract({ address: stakeToken, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
|
|
124
210
|
bountyBalance: () => client.readContract({ address: bountyToken, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
|
|
125
211
|
stake: () => client.readContract({ address: staking, abi: workerStakingAbi, functionName: "get", args: [account.address] }),
|
|
126
212
|
minStake: () => client.readContract({ address: staking, abi: workerStakingAbi, functionName: "minStake" }),
|
|
127
|
-
|
|
213
|
+
held: () => client.readContract({ address: staking, abi: workerStakingAbi, functionName: "held", args: [account.address] }),
|
|
128
214
|
allowed: () => client.readContract({ ...board, functionName: "workerAllowed", args: [account.address] }),
|
|
129
215
|
bond: () => client.readContract({ address: bonds, abi: bondsAbi, functionName: "bond" }),
|
|
130
216
|
async approve(token, spender, amount) {
|
|
@@ -134,35 +220,56 @@ export function createChain(network, account) {
|
|
|
134
220
|
await send({ address: token, abi: erc20Abi, functionName: "approve", args: [spender, amount] });
|
|
135
221
|
},
|
|
136
222
|
claimStakeToken: () => send({ address: stakeToken, abi: erc20Abi, functionName: "faucet" }),
|
|
223
|
+
/** What the stake token's faucet gives, and from when this wallet can claim it. */
|
|
224
|
+
async faucet() {
|
|
225
|
+
const token = { address: stakeToken, abi: erc20Abi };
|
|
226
|
+
const [amount, interval, last] = await Promise.all([
|
|
227
|
+
client.readContract({ ...token, functionName: "FAUCET_AMOUNT" }),
|
|
228
|
+
client.readContract({ ...token, functionName: "FAUCET_INTERVAL" }),
|
|
229
|
+
client.readContract({ ...token, functionName: "lastClaim", args: [account.address] }),
|
|
230
|
+
]);
|
|
231
|
+
return { amount, opensAt: last === 0n ? 0n : last + interval };
|
|
232
|
+
},
|
|
137
233
|
stakeTokens: (amount, price) => send({ address: staking, abi: workerStakingAbi, functionName: "stake", args: [amount, price] }),
|
|
138
234
|
setPrice: (price) => send({ address: staking, abi: workerStakingAbi, functionName: "setPrice", args: [price] }),
|
|
139
235
|
requestUnstake: (amount) => send({ address: staking, abi: workerStakingAbi, functionName: "requestUnstake", args: [amount] }),
|
|
140
236
|
withdraw: () => send({ address: staking, abi: workerStakingAbi, functionName: "withdraw" }),
|
|
141
237
|
apply: (profileURI) => send({ address: bonds, abi: bondsAbi, functionName: "propose", args: [taskBoard, applicationData(account.address), profileURI] }),
|
|
142
238
|
/**
|
|
143
|
-
* This worker's applications, newest last, with their state
|
|
144
|
-
*
|
|
145
|
-
*
|
|
239
|
+
* This worker's applications, newest last, with their state and the
|
|
240
|
+
* decision that settled each, and the last change any of them queued.
|
|
241
|
+
* Everything is read at one block, so a bond settled just after its change
|
|
242
|
+
* was queued is never seen without the change.
|
|
146
243
|
*/
|
|
147
244
|
async applications() {
|
|
148
245
|
const data = applicationData(account.address).toLowerCase();
|
|
149
246
|
const blockNumber = await client.getBlockNumber();
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
247
|
+
const proposed = (await scan((fromBlock, toBlock) => client.getContractEvents({ address: bonds, abi: bondsAbi, eventName: "Proposed", args: { proposer: account.address, target: taskBoard }, fromBlock, toBlock }), network.fromBlock, blockNumber)).filter((log) => log.args.data.toLowerCase() === data);
|
|
248
|
+
// Queued does not index the call, so its scan narrows to the board as target and to blocks from this worker's first application on.
|
|
249
|
+
const since = proposed[0]?.blockNumber ?? blockNumber + 1n;
|
|
250
|
+
const [settled, queued] = await Promise.all([
|
|
251
|
+
scan((fromBlock, toBlock) => client.getContractEvents({ address: bonds, abi: bondsAbi, eventName: "Settled", args: { id: proposed.map((log) => log.args.id) }, fromBlock, toBlock }), since, blockNumber),
|
|
252
|
+
scan((fromBlock, toBlock) => client.getContractEvents({ address: executor, abi: executorAbi, eventName: "Queued", args: { target: taskBoard }, fromBlock, toBlock }), since, blockNumber),
|
|
153
253
|
]);
|
|
154
254
|
const mine = queued.filter((log) => log.args.data.toLowerCase() === data).at(-1);
|
|
155
|
-
const change = mine
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
255
|
+
const change = mine && {
|
|
256
|
+
...(await client.readContract({ address: executor, abi: executorAbi, functionName: "getChange", args: [mine.args.changeId], blockNumber })),
|
|
257
|
+
decisionId: mine.args.decisionId,
|
|
258
|
+
};
|
|
259
|
+
const decided = new Map(settled.map((log) => [log.args.id, log.args.decisionId]));
|
|
260
|
+
const applications = await Promise.all(proposed.map(async (log) => ({
|
|
261
|
+
id: log.args.id,
|
|
262
|
+
state: (await client.readContract({ address: bonds, abi: bondsAbi, functionName: "get", args: [log.args.id], blockNumber })).state,
|
|
263
|
+
decisionId: decided.get(log.args.id),
|
|
264
|
+
})));
|
|
159
265
|
return { applications, change };
|
|
160
266
|
},
|
|
161
267
|
/** Tasks this worker was hired for, by outcome, and what it was paid after fees. */
|
|
162
268
|
async record() {
|
|
163
|
-
const
|
|
269
|
+
const latest = await client.getBlockNumber();
|
|
270
|
+
const ids = [...new Set((await hiredLogs(network.fromBlock, latest)).map((log) => log.args.id))];
|
|
164
271
|
const tasks = await Promise.all(ids.map((task) => client.readContract({ ...board, functionName: "get", args: [task] })));
|
|
165
|
-
const paid = await scan((fromBlock, toBlock) => client.getContractEvents({ ...board, eventName: "BountyPaid", args: { account: account.address }, fromBlock, toBlock }));
|
|
272
|
+
const paid = await scan((fromBlock, toBlock) => client.getContractEvents({ ...board, eventName: "BountyPaid", args: { account: account.address }, fromBlock, toBlock }), network.fromBlock, latest);
|
|
166
273
|
const count = (statuses) => tasks.filter((t) => statuses.includes(t.status)).length;
|
|
167
274
|
return {
|
|
168
275
|
accepted: count([Status.Paid]),
|
|
@@ -173,28 +280,58 @@ export function createChain(network, account) {
|
|
|
173
280
|
},
|
|
174
281
|
};
|
|
175
282
|
}
|
|
283
|
+
/** Briefs come from strangers, so the worker fetches only public names, never an address or a local network's name. */
|
|
284
|
+
function publicHost(hostname) {
|
|
285
|
+
const host = hostname.toLowerCase().replace(/\.$/, "");
|
|
286
|
+
if (host.startsWith("[") || isIP(host) !== 0)
|
|
287
|
+
return false;
|
|
288
|
+
return host.includes(".") && !/(^|\.)localhost$|\.local$|\.internal$/.test(host);
|
|
289
|
+
}
|
|
290
|
+
async function download(uri, fetcher) {
|
|
291
|
+
if (!URL.canParse(uri))
|
|
292
|
+
throw new PermanentError("The brief's URL is not valid");
|
|
293
|
+
const url = new URL(uri);
|
|
294
|
+
if (!publicHost(url.hostname))
|
|
295
|
+
throw new PermanentError(`The brief is on ${url.hostname}, which is not a public host`);
|
|
296
|
+
const stop = new AbortController();
|
|
297
|
+
const response = await fetcher(url, { redirect: "error", signal: AbortSignal.any([stop.signal, AbortSignal.timeout(15_000)]) });
|
|
298
|
+
if (!response.ok)
|
|
299
|
+
throw new Error(`The brief answered ${response.status}`);
|
|
300
|
+
const chunks = [];
|
|
301
|
+
let size = 0;
|
|
302
|
+
for await (const chunk of response.body ?? []) {
|
|
303
|
+
size += chunk.byteLength;
|
|
304
|
+
if (size > MAX_BRIEF_BYTES) {
|
|
305
|
+
stop.abort();
|
|
306
|
+
throw new PermanentError(`The brief is larger than ${MAX_BRIEF_BYTES / 1024} KB`);
|
|
307
|
+
}
|
|
308
|
+
chunks.push(chunk);
|
|
309
|
+
}
|
|
310
|
+
return Uint8Array.from(Buffer.concat(chunks));
|
|
311
|
+
}
|
|
176
312
|
/** Reads a brief and checks it against the hash on chain. Briefs are base64 data URIs or https URLs. */
|
|
177
313
|
export async function fetchBrief(uri, expected, fetcher = fetch) {
|
|
178
|
-
const MAX = 256 * 1024;
|
|
179
314
|
let bytes;
|
|
180
315
|
if (uri.startsWith("data:")) {
|
|
181
316
|
const comma = uri.indexOf(",");
|
|
182
317
|
if (comma < 0 || !uri.slice(0, comma).endsWith(";base64"))
|
|
183
|
-
throw new
|
|
318
|
+
throw new PermanentError("The brief's data URI is not base64");
|
|
184
319
|
bytes = Uint8Array.from(Buffer.from(uri.slice(comma + 1), "base64"));
|
|
185
320
|
}
|
|
186
321
|
else if (uri.startsWith("https://")) {
|
|
187
|
-
|
|
188
|
-
if (!response.ok)
|
|
189
|
-
throw new Error(`The brief answered ${response.status}`);
|
|
190
|
-
bytes = new Uint8Array(await response.arrayBuffer());
|
|
322
|
+
bytes = await download(uri, fetcher);
|
|
191
323
|
}
|
|
192
324
|
else {
|
|
193
|
-
throw new
|
|
325
|
+
throw new PermanentError("The brief is neither a data URI nor an https URL");
|
|
194
326
|
}
|
|
195
|
-
if (bytes.byteLength >
|
|
196
|
-
throw new
|
|
327
|
+
if (bytes.byteLength > MAX_BRIEF_BYTES)
|
|
328
|
+
throw new PermanentError(`The brief is larger than ${MAX_BRIEF_BYTES / 1024} KB`);
|
|
197
329
|
if (keccak256(bytes).toLowerCase() !== expected.toLowerCase())
|
|
198
|
-
throw new
|
|
199
|
-
|
|
330
|
+
throw new PermanentError("The brief does not match its hash on chain");
|
|
331
|
+
try {
|
|
332
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
throw new PermanentError("The brief is not UTF-8 text");
|
|
336
|
+
}
|
|
200
337
|
}
|