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/dist/chain.js CHANGED
@@ -1,19 +1,28 @@
1
- import { createPublicClient, createWalletClient, encodeFunctionData, http, keccak256, parseAbi, toBytes, } from "viem";
2
- import { taskBoardAbi, workerStakingAbi } from "./abi.js";
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 = parseAbi([
12
- "event Proposed(uint256 indexed id, address indexed proposer, address indexed target, bytes data, uint128 bond, string uri)",
13
- "function propose(address target, bytes data, string uri) returns (uint256)",
14
- "function get(uint256 id) view returns ((address proposer, uint128 bond, uint64 createdAt, uint8 state, bytes32 subject))",
15
- "function bond() view returns (uint128)",
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
- /** What the worker signs to upload a result: the task and the hash of exactly the bytes it uploads. */
27
- export function resultMessage(task, hash) {
28
- return `BrainChain result\ntask ${task}\nhash ${hash}`;
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
- export function createChain(network, account) {
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 up to `latest`, in windows the RPC accepts. */
54
- async function scan(fetch, upTo) {
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 from = network.fromBlock; from <= latest; from += LOG_RANGE) {
58
- const to = from + LOG_RANGE - 1n < latest ? from + LOG_RANGE - 1n : latest;
59
- all.push(...(await fetch(from, to)));
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 hiredIds = () => scan((fromBlock, toBlock) => client.getContractEvents({ ...board, eventName: "WorkerHired", args: { model: id }, fromBlock, toBlock })).then((logs) => logs.map((log) => log.args.id));
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 ids = (await hiredIds()).filter((t) => !settled.has(t));
85
- const tasks = [];
86
- for (const task of ids) {
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 !== Status.Hired || t.model.toLowerCase() !== id) {
89
- if (t.status >= Status.Paid)
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
- tasks.push({ id: task, deadline: t.deadline, briefURI: await briefURI(task), briefHash: t.briefHash });
94
- }
95
- return tasks;
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 (${response.status}): ${body.error ?? "no reason given"}`);
109
- if (body.hash?.toLowerCase() !== hash.toLowerCase())
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
- inFlight: () => client.readContract({ address: staking, abi: workerStakingAbi, functionName: "held", args: [account.address] }),
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, and the last
144
- * change any of them queued. Everything is read at one block, so a bond
145
- * settled just after its change was queued is never seen without the change.
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 [proposed, queued] = await Promise.all([
151
- scan((fromBlock, toBlock) => client.getContractEvents({ address: bonds, abi: bondsAbi, eventName: "Proposed", args: { proposer: account.address, target: taskBoard }, fromBlock, toBlock }), blockNumber),
152
- scan((fromBlock, toBlock) => client.getContractEvents({ address: executor, abi: executorAbi, eventName: "Queued", args: { target: taskBoard }, fromBlock, toBlock }), blockNumber),
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 ? await client.readContract({ address: executor, abi: executorAbi, functionName: "getChange", args: [mine.args.changeId], blockNumber }) : undefined;
156
- const applications = await Promise.all(proposed
157
- .filter((log) => log.args.data.toLowerCase() === data)
158
- .map(async (log) => ({ id: log.args.id, state: (await client.readContract({ address: bonds, abi: bondsAbi, functionName: "get", args: [log.args.id], blockNumber })).state })));
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 ids = [...new Set(await hiredIds())];
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 Error("The brief's data URI is not base64");
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
- const response = await fetcher(uri, { redirect: "error", signal: AbortSignal.timeout(15_000) });
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 Error("The brief is neither a data URI nor an https URL");
325
+ throw new PermanentError("The brief is neither a data URI nor an https URL");
194
326
  }
195
- if (bytes.byteLength > MAX)
196
- throw new Error("The brief is too large");
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 Error("The brief does not match its hash on chain");
199
- return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
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
  }