brainchain-worker 0.13.1 → 0.15.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/README.md +27 -17
- package/dist/abi.js +70 -0
- package/dist/bridge.js +4 -2
- package/dist/chain.js +81 -13
- package/dist/cli.js +117 -32
- package/dist/dispute.js +92 -16
- package/dist/key.js +2 -2
- package/dist/mint.js +68 -31
- package/dist/network.js +11 -6
- package/dist/proc.js +34 -0
- package/dist/propose.js +3 -2
- package/dist/standing.js +63 -7
- package/dist/state.js +21 -4
- package/dist/work.js +22 -16
- package/package.json +2 -2
package/dist/mint.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createPublicClient, createWalletClient, formatEther, parseAbi, parseEventLogs, zeroAddress, } from "viem";
|
|
2
|
-
import { reason, RECEIPT_MS } from "./chain.js";
|
|
2
|
+
import { reason, RECEIPT_MS, REFUND_REASONS } from "./chain.js";
|
|
3
3
|
import { proofsChainOf, proofsTransport } from "./network.js";
|
|
4
|
-
import { when } from "./standing.js";
|
|
4
|
+
import { stake, usdg, when } from "./standing.js";
|
|
5
5
|
export const collectionAbi = parseAbi([
|
|
6
6
|
"function PRICE_QUOTE() view returns (uint256)",
|
|
7
7
|
"function mint(uint256 quantity, uint256 minRoutedTotal) payable",
|
|
@@ -23,13 +23,13 @@ export const wethAbi = parseAbi([
|
|
|
23
23
|
export const MAX_MINT = 10;
|
|
24
24
|
/** How often a delivered task's status is read until it settles. */
|
|
25
25
|
const STATUS_SEC = 300;
|
|
26
|
-
/**
|
|
27
|
-
const WATCH_SEC = 86_400;
|
|
26
|
+
/** Each step from grading to a ruling on appeal has a window of at most 6 hours, so a task unsettled two days after delivery is left. */
|
|
27
|
+
const WATCH_SEC = 2 * 86_400;
|
|
28
28
|
/** A day after payment the task joins the public pool, so the worker's own attempts stop an hour short of it. */
|
|
29
29
|
const OWN_SEC = 23 * 3_600;
|
|
30
30
|
/** The least time between two mint attempts for one task. */
|
|
31
31
|
const RETRY_SEC = 3_600;
|
|
32
|
-
/** The service lists a payment a round or two after it lands
|
|
32
|
+
/** The service lists a payment a round or two after it lands, which has taken over ten minutes: this long, the listing is read every minute. */
|
|
33
33
|
const LISTING_SEC = 900;
|
|
34
34
|
/** The piece a mint's receipt shows the collection minting to `to`. */
|
|
35
35
|
export function mintedToken(logs, collection, to) {
|
|
@@ -140,7 +140,11 @@ async function afford(proofs, chain, count, options) {
|
|
|
140
140
|
: `The test WETH faucet allows one claim a day, and this wallet can claim again at ${when(opensAt)}.`);
|
|
141
141
|
}
|
|
142
142
|
const holds = `${chain.account} holds ${formatEther(weth)} WETH${needEth ? " and no ETH" : ""} on ${proofs.name}`;
|
|
143
|
-
return {
|
|
143
|
+
return {
|
|
144
|
+
price,
|
|
145
|
+
missing: `${holds}, and ${proofCount(count)} cost${count === 1 ? "s" : ""} ${formatEther(cost)} WETH plus gas. ${fixes.join(" ")}`,
|
|
146
|
+
...(needEth ? { noEth: true } : {}),
|
|
147
|
+
};
|
|
144
148
|
}
|
|
145
149
|
/**
|
|
146
150
|
* Mints a piece for each of the worker's paid tasks the service lists without
|
|
@@ -178,13 +182,14 @@ export async function mint(network, chain, options) {
|
|
|
178
182
|
log(rest.trim());
|
|
179
183
|
}
|
|
180
184
|
/**
|
|
181
|
-
* What `run` does
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
* mint.
|
|
185
|
+
* What `run` does once a task is delivered: it watches the task until it
|
|
186
|
+
* settles and logs how: paid and how much, or refunded, why and any stake
|
|
187
|
+
* slashed. With `mints`, it then mints a piece for each paid task, at most once
|
|
188
|
+
* an hour per task, while the service lists the task as waiting for one and
|
|
189
|
+
* the worker can still mint it. The watched tasks live in the state, so a
|
|
190
|
+
* restart neither forgets nor repeats a mint.
|
|
186
191
|
*/
|
|
187
|
-
export function
|
|
192
|
+
export function createSettlementWatch(options) {
|
|
188
193
|
const { network, chain, store, log } = options;
|
|
189
194
|
const clock = options.now ?? wall;
|
|
190
195
|
const watched = () => (store.state.proofs ??= {});
|
|
@@ -198,49 +203,77 @@ export function createProofMinter(options) {
|
|
|
198
203
|
entry.nextAt = clock() + seconds;
|
|
199
204
|
store.save();
|
|
200
205
|
};
|
|
201
|
-
/** Reads whether a delivered task settled; true once it is paid. */
|
|
206
|
+
/** Reads whether a delivered task settled and logs how; true once it is paid and has a Proof to mint. */
|
|
202
207
|
async function settle(key, entry) {
|
|
203
208
|
let outcome;
|
|
204
209
|
try {
|
|
205
|
-
outcome = await options.
|
|
210
|
+
outcome = await options.settlement(BigInt(key));
|
|
206
211
|
}
|
|
207
212
|
catch (error) {
|
|
208
213
|
later(entry, STATUS_SEC);
|
|
209
|
-
log(`task ${key}: could not read whether it
|
|
210
|
-
return false;
|
|
211
|
-
}
|
|
212
|
-
if (outcome === "refunded") {
|
|
213
|
-
drop(key, "refunded to its poster, so it gets no proof");
|
|
214
|
+
log(`task ${key}: could not read whether it settled (${reason(error)})`);
|
|
214
215
|
return false;
|
|
215
216
|
}
|
|
216
217
|
if (outcome === undefined) {
|
|
217
218
|
if (clock() - entry.deliveredAt >= WATCH_SEC)
|
|
218
|
-
drop(key, "not settled
|
|
219
|
+
drop(key, "not settled two days after delivery, so the worker stops watching it");
|
|
219
220
|
else
|
|
220
221
|
later(entry, STATUS_SEC);
|
|
221
222
|
return false;
|
|
222
223
|
}
|
|
223
|
-
|
|
224
|
+
if (!outcome.paid) {
|
|
225
|
+
const slashed = outcome.slashed > 0n ? `; ${stake(network, outcome.slashed)} of the worker's stake was slashed` : "";
|
|
226
|
+
drop(key, `refunded to its poster: ${REFUND_REASONS[outcome.reason] ?? `reason ${outcome.reason}`}${slashed}`);
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
log(`task ${key}: paid ${usdg(network, outcome.amount)}`);
|
|
230
|
+
if (!options.mints) {
|
|
231
|
+
drop(key);
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
entry.paidAt = Number(outcome.at);
|
|
224
235
|
store.save();
|
|
225
236
|
return true;
|
|
226
237
|
}
|
|
227
238
|
async function mintFor(key, entry, listed) {
|
|
228
|
-
const
|
|
229
|
-
if (clock()
|
|
239
|
+
const until = entry.paidAt + OWN_SEC;
|
|
240
|
+
if (clock() >= until) {
|
|
241
|
+
if (Array.isArray(listed) && !listed.includes(BigInt(key)) && !entry.listed) {
|
|
242
|
+
return drop(key, "the BrainChain service never listed it as waiting for a proof, so the worker minted none");
|
|
243
|
+
}
|
|
230
244
|
return drop(key, "no proof minted within a day of payment, so the task joins the public pool");
|
|
245
|
+
}
|
|
231
246
|
if (Array.isArray(listed) && !listed.includes(BigInt(key))) {
|
|
232
|
-
|
|
247
|
+
// Once listed, a task leaves the list when it has its piece.
|
|
248
|
+
if (entry.listed)
|
|
249
|
+
return drop(key, "the BrainChain service no longer lists it as waiting for a proof, so it has one");
|
|
250
|
+
if (clock() - entry.paidAt < LISTING_SEC)
|
|
233
251
|
return later(entry, 60);
|
|
234
|
-
|
|
252
|
+
if (!entry.waited) {
|
|
253
|
+
entry.waited = true;
|
|
254
|
+
log(`task ${key}: the BrainChain service does not list it as waiting for a proof yet; checking every ${STATUS_SEC / 60} minutes until ${when(BigInt(until))}`);
|
|
255
|
+
}
|
|
256
|
+
return later(entry, STATUS_SEC);
|
|
235
257
|
}
|
|
258
|
+
if (Array.isArray(listed))
|
|
259
|
+
entry.listed = true;
|
|
236
260
|
// Counted before anything is sent, so a restart during the mint waits out the hour too.
|
|
237
261
|
later(entry, RETRY_SEC);
|
|
238
262
|
if (listed instanceof Error)
|
|
239
263
|
return log(`task ${key}: no proof minted yet (${reason(listed)}); trying again in an hour`);
|
|
240
264
|
try {
|
|
241
265
|
const funds = await afford(network.proofs, chain, 1, { dry: false, claiming: () => log("claiming test WETH from the faucet"), now: clock });
|
|
242
|
-
if (funds.missing)
|
|
243
|
-
|
|
266
|
+
if (funds.missing) {
|
|
267
|
+
if (network.id !== "testnet" || !funds.noEth)
|
|
268
|
+
return log(`task ${key}: no proof minted yet. ${funds.missing} Trying again in an hour.`);
|
|
269
|
+
// No agent route leads to ETH on Robinhood Chain testnet, so the hourly retries say nothing new.
|
|
270
|
+
if (entry.told)
|
|
271
|
+
return;
|
|
272
|
+
entry.told = true;
|
|
273
|
+
store.save();
|
|
274
|
+
return log(`task ${key}: no proof minted. ${funds.missing} It tries again every hour, without another line, until ${when(BigInt(until))}; ` +
|
|
275
|
+
"an hour later the Proof goes to the public pool. Proofs earn no income, and `run` without `--mint` mints none.");
|
|
276
|
+
}
|
|
244
277
|
await chain.approve(funds.price);
|
|
245
278
|
const { tokenId, tx } = await chain.mint();
|
|
246
279
|
drop(key);
|
|
@@ -253,15 +286,13 @@ export function createProofMinter(options) {
|
|
|
253
286
|
return {
|
|
254
287
|
/** Watches a task the worker just delivered. */
|
|
255
288
|
track(task) {
|
|
256
|
-
if (!options.enabled)
|
|
257
|
-
return;
|
|
258
289
|
const now = clock();
|
|
259
290
|
watched()[task.toString()] = { deliveredAt: now, nextAt: now + STATUS_SEC };
|
|
260
291
|
store.save();
|
|
261
292
|
},
|
|
262
|
-
/** Reads every watched task that is due, and mints for each paid one. */
|
|
293
|
+
/** Reads every watched task that is due, logs each that settled, and mints for each paid one. */
|
|
263
294
|
async round() {
|
|
264
|
-
if (
|
|
295
|
+
if (store.state.proofs === undefined)
|
|
265
296
|
return;
|
|
266
297
|
const due = Object.entries(watched()).filter(([, entry]) => clock() >= entry.nextAt);
|
|
267
298
|
const paid = [];
|
|
@@ -270,6 +301,12 @@ export function createProofMinter(options) {
|
|
|
270
301
|
paid.push([key, entry]);
|
|
271
302
|
if (paid.length === 0)
|
|
272
303
|
return;
|
|
304
|
+
// A run under --no-mint after one that minted leaves those tasks' Proofs to `mint`.
|
|
305
|
+
if (!options.mints) {
|
|
306
|
+
for (const [key] of paid)
|
|
307
|
+
drop(key);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
273
310
|
const listed = await pendingTasks(network, chain.account, options.fetcher).catch((error) => (error instanceof Error ? error : new Error(String(error))));
|
|
274
311
|
for (const [key, entry] of paid)
|
|
275
312
|
await mintFor(key, entry, listed);
|
package/dist/network.js
CHANGED
|
@@ -10,10 +10,13 @@ export const TESTNET = {
|
|
|
10
10
|
explorer: "https://usebrainchain.com/testnet",
|
|
11
11
|
stakeSymbol: "tBRAIN",
|
|
12
12
|
bountySymbol: "tUSDG",
|
|
13
|
-
|
|
14
|
-
gas:
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
bountyFaucet: true,
|
|
14
|
+
gas: "Get Robinhood Chain testnet ETH from https://faucet.testnet.chain.robinhood.com, which asks for a browser check and a Google account, " +
|
|
15
|
+
"so a person claims it, and move it here with `npx brainchain-worker bridge --eth 0.001`",
|
|
16
|
+
drip: {
|
|
17
|
+
wei: 100000000000000n,
|
|
18
|
+
request: (address) => `\`curl -X POST https://brain.testnet.usebrainchain.com/testnet/gas -H 'content-type: application/json' -d '{"address":"${address}"}'\``,
|
|
19
|
+
},
|
|
17
20
|
proofs: {
|
|
18
21
|
chainId: 46630,
|
|
19
22
|
name: "Robinhood Chain testnet",
|
|
@@ -22,7 +25,9 @@ export const TESTNET = {
|
|
|
22
25
|
collection: "0x324B52EE2182641ec29652076ab541bc9F9907d2",
|
|
23
26
|
weth: "0x1F852eF3Afa13156b807204d838BbB46B4b452eb",
|
|
24
27
|
faucet: true,
|
|
25
|
-
gas
|
|
28
|
+
// The gas drip funds BrainChain testnet only, and this faucet asks for a browser check, so a person has to claim it.
|
|
29
|
+
gas: "A person can get Robinhood Chain testnet ETH for gas at https://faucet.testnet.chain.robinhood.com, which asks for a browser check and a Google account, " +
|
|
30
|
+
"and send it to this address; BrainChain's gas drip sends ETH on BrainChain testnet only",
|
|
26
31
|
},
|
|
27
32
|
bridge: {
|
|
28
33
|
chainId: 46630,
|
|
@@ -61,7 +66,7 @@ export const MAINNET = {
|
|
|
61
66
|
explorer: "https://usebrainchain.com",
|
|
62
67
|
stakeSymbol: "BRAIN",
|
|
63
68
|
bountySymbol: "USDG",
|
|
64
|
-
gas:
|
|
69
|
+
gas: "Send this address ETH on Robinhood Chain, then move it here with `npx brainchain-worker bridge --eth 0.001`",
|
|
65
70
|
buyStake: "Buy BRAIN on Robinhood Chain at ripples.run (https://ripples.run/t/0xa06e3D898Bce92F67617E0Bfb640F153A5b65AB7) and move it to BrainChain with " +
|
|
66
71
|
`brainchain-worker bridge --stake <amount> (docs: https://docs.usebrainchain.com/bridging); the stake token on BrainChain is ${BRAIN}.`,
|
|
67
72
|
proofs: {
|
package/dist/proc.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
/** One field of `ps` for a process, or undefined when it is gone or ps cannot tell. */
|
|
4
|
+
export function ps(pid, field) {
|
|
5
|
+
const result = spawnSync("ps", ["-o", `${field}=`, "-p", String(pid)], { encoding: "utf8" });
|
|
6
|
+
return result.status === 0 ? result.stdout.trim() : undefined;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* When a Linux process started, from /proc, which slim container images have
|
|
10
|
+
* without ps. The kernel counts it in ticks since boot, 100 a second on every
|
|
11
|
+
* architecture Node.js runs on.
|
|
12
|
+
*/
|
|
13
|
+
function procStart(pid) {
|
|
14
|
+
try {
|
|
15
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
16
|
+
const ticks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
|
|
17
|
+
const boot = Number(/^btime (\d+)$/m.exec(readFileSync("/proc/stat", "utf8"))?.[1]);
|
|
18
|
+
return Number.isSafeInteger(ticks) && boot > 0 ? boot * 1000 + ticks * 10 : undefined;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** When a process started, in ms since the epoch, or undefined when it is gone or neither /proc nor ps can tell. */
|
|
25
|
+
export function startTime(pid) {
|
|
26
|
+
const started = procStart(pid);
|
|
27
|
+
if (started !== undefined)
|
|
28
|
+
return started;
|
|
29
|
+
const elapsed = /^(?:(?:(\d+)-)?(\d+):)?(\d+):(\d+)$/.exec(ps(pid, "etime") ?? "");
|
|
30
|
+
if (!elapsed)
|
|
31
|
+
return undefined;
|
|
32
|
+
const [days, hours, minutes, seconds] = elapsed.slice(1).map((part) => Number(part ?? 0));
|
|
33
|
+
return Date.now() - (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;
|
|
34
|
+
}
|
package/dist/propose.js
CHANGED
|
@@ -2,7 +2,7 @@ import { BaseError, createPublicClient, createWalletClient, decodeErrorResult, d
|
|
|
2
2
|
import { erc20Errors } from "./abi.js";
|
|
3
3
|
import { bondsAbi, brainchainTransport, erc20Abi, executorAbi, RECEIPT_MS } from "./chain.js";
|
|
4
4
|
import { chainOf } from "./network.js";
|
|
5
|
-
import { stake, when } from "./standing.js";
|
|
5
|
+
import { gasHelp, stake, when } from "./standing.js";
|
|
6
6
|
/** Every call the executor can be asked to make, by the contract it calls. */
|
|
7
7
|
export const GOVERNED = {
|
|
8
8
|
board: parseAbi([
|
|
@@ -198,6 +198,7 @@ export function createProposals(network, account) {
|
|
|
198
198
|
account: account.address,
|
|
199
199
|
now: async () => (await client.getBlock()).timestamp,
|
|
200
200
|
ethBalance: () => client.getBalance({ address: account.address }),
|
|
201
|
+
sent: () => client.getTransactionCount({ address: account.address }),
|
|
201
202
|
stakeBalance: () => client.readContract({ address: stakeToken, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
|
|
202
203
|
bond: () => client.readContract({ ...proposals, functionName: "bond" }),
|
|
203
204
|
decideWithin: () => client.readContract({ ...proposals, functionName: "decideWithin" }),
|
|
@@ -303,7 +304,7 @@ export async function propose(network, chain, call, uri, options) {
|
|
|
303
304
|
throw new Error(`The wallet holds ${stake(network, balance)} of the ${stake(network, bond)} bond.${buy}`);
|
|
304
305
|
}
|
|
305
306
|
if (gas === 0n)
|
|
306
|
-
throw new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.
|
|
307
|
+
throw new Error(`${chain.account} has no ETH on ${network.name} for gas. ${gasHelp(network, chain.account, 0n, await chain.sent())}.`);
|
|
307
308
|
log(`Bonding ${stake(network, bond)} and proposing...`);
|
|
308
309
|
const { id, tx } = await chain.propose(call, uri, bond);
|
|
309
310
|
log(`Proposal ${id} is waiting for Jev (${tx}). Follow it with \`npx brainchain-worker proposal ${id}\`.`);
|
package/dist/standing.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { formatEther, formatUnits } from "viem";
|
|
2
|
-
import { ApplicationState } from "./chain.js";
|
|
2
|
+
import { ApplicationState, Status } from "./chain.js";
|
|
3
3
|
/** A token amount with thousands separators, such as 1,250,000; a fraction is kept as it is. */
|
|
4
|
-
function grouped(amount, decimals) {
|
|
4
|
+
export function grouped(amount, decimals) {
|
|
5
5
|
const [whole, fraction] = formatUnits(amount, decimals).split(".");
|
|
6
6
|
return `${whole.replace(/\B(?=(\d{3})+$)/g, ",")}${fraction === undefined ? "" : `.${fraction}`}`;
|
|
7
7
|
}
|
|
@@ -27,7 +27,7 @@ export async function snapshot(chain) {
|
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
29
|
/** The highest gas limit seen for each transaction join can send, across testnet and mainnet. */
|
|
30
|
-
const GAS = { faucet: 76000n, approve: 50000n, stake: 120000n, setPrice: 31000n, apply: 240000n };
|
|
30
|
+
export const GAS = { faucet: 76000n, approve: 50000n, stake: 120000n, setPrice: 31000n, apply: 240000n };
|
|
31
31
|
/** The highest gas limit seen for delivering a result. */
|
|
32
32
|
const SUBMIT_GAS = 104000n;
|
|
33
33
|
/**
|
|
@@ -105,13 +105,42 @@ export function wallet(network, s, balance, bond, faucet, now) {
|
|
|
105
105
|
const buy = network.buyStake ? `. ${network.buyStake}` : "";
|
|
106
106
|
return `${has}; join needs ${stake(network, need)}${reopens}${unstaking}${buy}`;
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Where a wallet gets ETH for gas, given what it holds and how many
|
|
110
|
+
* transactions it has sent. The drip serves an address once and turns away one
|
|
111
|
+
* that holds a drip's worth, so only a wallet that has sent nothing and holds
|
|
112
|
+
* less is sure to get it; any other is sent to the faucet.
|
|
113
|
+
*/
|
|
114
|
+
export function gasHelp(network, address, gas, sent) {
|
|
115
|
+
const { drip } = network;
|
|
116
|
+
if (!drip)
|
|
117
|
+
return network.gas;
|
|
118
|
+
if (sent > 0 || gas >= drip.wei)
|
|
119
|
+
return `${network.gas}. The gas drip serves each address once`;
|
|
120
|
+
const after = `${network.gas[0].toLowerCase()}${network.gas.slice(1)}`;
|
|
121
|
+
return `Ask the testnet gas drip first: ${drip.request(address)} sends ${formatEther(drip.wei)} ETH, enough to join, once per address. After that, ${after}`;
|
|
122
|
+
}
|
|
108
123
|
/** What a wallet short of gas holds and needs, for join's refusal and for `status`. */
|
|
109
|
-
export const shortOfGas = (network, address, gas, need, purpose) => `${formatEther(gas)} ETH on ${network.name}; ${purpose} needs about ${aboutEth(need)} ETH for gas. ${network
|
|
110
|
-
/**
|
|
111
|
-
export function
|
|
124
|
+
export const shortOfGas = (network, address, gas, sent, need, purpose) => `${formatEther(gas)} ETH on ${network.name}; ${purpose} needs about ${aboutEth(need)} ETH for gas. ${gasHelp(network, address, gas, sent)}.`;
|
|
125
|
+
/** How many results the wallet's ETH delivers at this gas price, the last of them at the fee cap the RPC checks. */
|
|
126
|
+
export function deliveriesLeft(gas, gasPrice) {
|
|
127
|
+
const price = gasPrice > 0n ? gasPrice : 1n;
|
|
128
|
+
const last = ethNeeded([SUBMIT_GAS], price);
|
|
129
|
+
return gas < last ? 0n : (gas - last) / (SUBMIT_GAS * price) + 1n;
|
|
130
|
+
}
|
|
131
|
+
/** About how many results the wallet's ETH delivers, for `status` and `run`. */
|
|
132
|
+
export const deliveries = (count) => `about ${count} ${count === 1n ? "delivery" : "deliveries"}`;
|
|
133
|
+
/**
|
|
134
|
+
* The wallet's ETH, and what more join or the next delivery needs, since a
|
|
135
|
+
* send the wallet cannot cover fails partway through. A worker with nothing to
|
|
136
|
+
* join hears how many results its ETH delivers.
|
|
137
|
+
*/
|
|
138
|
+
export function gasLine(network, address, s, gas, gasPrice, claims, sent) {
|
|
112
139
|
const limits = joinLimits(s, claims, false);
|
|
113
140
|
const need = ethNeeded(limits.length > 0 ? limits : [SUBMIT_GAS], gasPrice);
|
|
114
|
-
|
|
141
|
+
if (gas < need)
|
|
142
|
+
return shortOfGas(network, address, gas, sent, need, limits.length > 0 ? "join" : "delivering a result");
|
|
143
|
+
return limits.length > 0 ? `${formatEther(gas)} ETH` : `${formatEther(gas)} ETH, enough for ${deliveries(deliveriesLeft(gas, gasPrice))} at the current gas price`;
|
|
115
144
|
}
|
|
116
145
|
/** What join says when the wallet holds too little of a stake token that has no faucet. */
|
|
117
146
|
export function shortOfStake(network, w, balance, need, now) {
|
|
@@ -119,3 +148,30 @@ export function shortOfStake(network, w, balance, need, now) {
|
|
|
119
148
|
const unstaking = w.unstaking > 0n ? ` Or run withdraw ${withdraw} and join with the ${stake(network, w.unstaking)} it returns.` : "";
|
|
120
149
|
return `The wallet holds ${stake(network, balance)} of the ${stake(network, need)} join needs. ${network.buyStake}${unstaking}`;
|
|
121
150
|
}
|
|
151
|
+
/** Where a task in flight stands and what happens next, for `status`. */
|
|
152
|
+
export function flightLine(f) {
|
|
153
|
+
const task = `task ${f.id}`;
|
|
154
|
+
switch (f.status) {
|
|
155
|
+
case Status.Hired:
|
|
156
|
+
return `${task}: hired, due by ${when(f.deadline)}`;
|
|
157
|
+
case Status.Submitted:
|
|
158
|
+
return `${task}: delivered, waiting for Jev's grade`;
|
|
159
|
+
case Status.Graded: {
|
|
160
|
+
const closes = when(f.gradedAt + f.disputeWindow);
|
|
161
|
+
return f.accepted
|
|
162
|
+
? `${task}: accepted, paid after ${closes} unless the grade is disputed`
|
|
163
|
+
: `${task}: not accepted, refunded to the poster after ${closes} unless the grade is disputed with \`dispute ${f.id}\``;
|
|
164
|
+
}
|
|
165
|
+
case Status.Disputed:
|
|
166
|
+
return `${task}: grade disputed by ${f.disputedByModel ? "this worker" : "the poster"}, Jev rules by ${when(f.disputedAt + f.ruleWindow)}`;
|
|
167
|
+
case Status.Ruled: {
|
|
168
|
+
const a = f.appeal;
|
|
169
|
+
const closes = when(a.ruledAt + a.window);
|
|
170
|
+
return a.pays
|
|
171
|
+
? `${task}: the ruling pays this worker after ${closes} unless the poster appeals`
|
|
172
|
+
: `${task}: the ruling refunds the poster after ${closes} unless appealed with \`appeal ${f.id}\``;
|
|
173
|
+
}
|
|
174
|
+
default:
|
|
175
|
+
return `${task}: ruling appealed, Jev rules again by ${when(f.appeal.appealedAt + f.ruleWindow)}`;
|
|
176
|
+
}
|
|
177
|
+
}
|
package/dist/state.js
CHANGED
|
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
|
|
|
2
2
|
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir, hostname } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
+
import { startTime } from "./proc.js";
|
|
5
6
|
export const stateDir = () => join(homedir(), ".config", "brainchain-worker");
|
|
6
7
|
/** The decision count a one-shot watch run stopped at, kept per decision log; undefined when there is none or it cannot be read. */
|
|
7
8
|
export function readWatched(log, dir = stateDir()) {
|
|
@@ -38,10 +39,11 @@ function isTask(value) {
|
|
|
38
39
|
return false;
|
|
39
40
|
return value.stored === undefined || (isObject(value.stored) && typeof value.stored.uri === "string" && isHash(value.stored.hash));
|
|
40
41
|
}
|
|
42
|
+
const isFlag = (value) => value === undefined || value === true;
|
|
41
43
|
function isProofs(value) {
|
|
42
44
|
if (!isObject(value) || !isIds(Object.keys(value)))
|
|
43
45
|
return false;
|
|
44
|
-
return Object.values(value).every((p) => isObject(p) && isCount(p.deliveredAt) && isCount(p.nextAt) && (p.paidAt === undefined || isCount(p.paidAt)));
|
|
46
|
+
return Object.values(value).every((p) => isObject(p) && isCount(p.deliveredAt) && isCount(p.nextAt) && (p.paidAt === undefined || isCount(p.paidAt)) && isFlag(p.listed) && isFlag(p.waited) && isFlag(p.told));
|
|
45
47
|
}
|
|
46
48
|
/** Whether a parsed file is a state this worker can go on from; any other shape fails every round that reads it. */
|
|
47
49
|
function isState(value) {
|
|
@@ -112,6 +114,8 @@ const LOCK_STALE_MS = 15_000;
|
|
|
112
114
|
const LOCK_WATCH_MS = 7_000;
|
|
113
115
|
/** A takeover takes a few system calls, so one marker older than this was left by a crash. */
|
|
114
116
|
const TAKEOVER_STALE_MS = 5_000;
|
|
117
|
+
/** How far a start time read from ps, which gives it to the second, can fall after the true one. */
|
|
118
|
+
const START_SLACK_MS = 2_000;
|
|
115
119
|
/**
|
|
116
120
|
* Where a pid means something. Containers sharing a config folder each have
|
|
117
121
|
* their own numbering, so a holder's pid says nothing outside its own space.
|
|
@@ -169,14 +173,27 @@ function holderOf(text) {
|
|
|
169
173
|
}
|
|
170
174
|
return { pid: 0, space: "" };
|
|
171
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Whether the run that wrote a bare-pid lock is still going. 0.12 and earlier
|
|
178
|
+
* wrote the pid once and never touched the file again, so the lock's age says
|
|
179
|
+
* nothing. A process that started after the lock was written, as pid 1 does
|
|
180
|
+
* when a container restarts, has only come to reuse the pid.
|
|
181
|
+
*/
|
|
182
|
+
function bareHeld(pid, writtenAt) {
|
|
183
|
+
if (!alive(pid))
|
|
184
|
+
return false;
|
|
185
|
+
const started = startTime(pid);
|
|
186
|
+
return started === undefined || started <= writtenAt + START_SLACK_MS;
|
|
187
|
+
}
|
|
172
188
|
/** Whether the run that wrote a lock is still going. */
|
|
173
189
|
function isHeld(file, { pid, space }, watchMs) {
|
|
174
190
|
const touched = touchedAt(file);
|
|
175
|
-
if (touched === undefined
|
|
191
|
+
if (touched === undefined)
|
|
176
192
|
return false;
|
|
177
|
-
// A bare pid was written in this PID namespace or one gone since, so after a restart a live pid can be another process.
|
|
178
193
|
if (space === undefined)
|
|
179
|
-
return
|
|
194
|
+
return bareHeld(pid, touched);
|
|
195
|
+
if (Date.now() - touched >= LOCK_STALE_MS)
|
|
196
|
+
return false;
|
|
180
197
|
if (pid === 0)
|
|
181
198
|
return true;
|
|
182
199
|
if (space === pidSpace())
|
package/dist/work.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { toBytes } from "viem";
|
|
4
4
|
import { fetchBrief, PermanentError, reason, RECEIPT_MS } from "./chain.js";
|
|
5
|
+
import { ps, startTime } from "./proc.js";
|
|
5
6
|
import { memoryStore } from "./state.js";
|
|
6
7
|
/** The most the service accepts for one result. */
|
|
7
8
|
export const MAX_RESULT_BYTES = 100_000;
|
|
@@ -15,10 +16,26 @@ const DEFAULT_TIMEOUT_MS = 20 * 60_000;
|
|
|
15
16
|
const DRAIN_MS = 2_000;
|
|
16
17
|
const ANSI = /\x1b(?:\[[0-?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])/g;
|
|
17
18
|
export const stripAnsi = (text) => text.replace(ANSI, "");
|
|
19
|
+
/** Characters that show nothing or turn the text around them, so text that holds them can read as other text. */
|
|
20
|
+
export const HIDDEN = /[\u00ad\u061c\u180e\u200b\u200e\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/;
|
|
21
|
+
/**
|
|
22
|
+
* What a worker's name and description may not hold: control, format and
|
|
23
|
+
* separator characters, and the default-ignorable code points Unicode files
|
|
24
|
+
* as letters or marks, which no property escape reaches, plus the blank
|
|
25
|
+
* braille pattern. Each shows nothing, breaks the line or turns the text
|
|
26
|
+
* around, so a name that holds one can read as another.
|
|
27
|
+
*/
|
|
28
|
+
const UNSEEN = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}͏ᅟᅠ឴឵᠋-᠏⠀ㅤ︀-️ᅠ-\u{e0000}-\u{e0fff}]/u;
|
|
29
|
+
/** The first character of `text` that UNSEEN refuses, as U+XXXX, or undefined when there is none. */
|
|
30
|
+
export function unseen(text) {
|
|
31
|
+
const found = UNSEEN.exec(text)?.[0];
|
|
32
|
+
return found === undefined ? undefined : `U+${found.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`;
|
|
33
|
+
}
|
|
18
34
|
/** Text from strangers, made safe to print as one terminal line. */
|
|
19
35
|
export const printable = (text) => stripAnsi(text)
|
|
20
36
|
.replace(/[\t\n\r]+/g, " ")
|
|
21
|
-
.replace(/[\x00-\x1f\x7f-\x9f]/g, "")
|
|
37
|
+
.replace(/[\x00-\x1f\x7f-\x9f]/g, "")
|
|
38
|
+
.replace(new RegExp(HIDDEN.source, "g"), "");
|
|
22
39
|
export function parseBrief(text) {
|
|
23
40
|
let value;
|
|
24
41
|
try {
|
|
@@ -93,19 +110,6 @@ function signalGroup(pid, signal) {
|
|
|
93
110
|
throw error;
|
|
94
111
|
}
|
|
95
112
|
}
|
|
96
|
-
/** One field of `ps` for a process, or undefined when it is gone or ps cannot tell. */
|
|
97
|
-
function ps(pid, field) {
|
|
98
|
-
const result = spawnSync("ps", ["-o", `${field}=`, "-p", String(pid)], { encoding: "utf8" });
|
|
99
|
-
return result.status === 0 ? result.stdout.trim() : undefined;
|
|
100
|
-
}
|
|
101
|
-
/** When a process started, in ms since the epoch, or undefined when it is gone or ps cannot tell. */
|
|
102
|
-
function startTime(pid) {
|
|
103
|
-
const elapsed = /^(?:(?:(\d+)-)?(\d+):)?(\d+):(\d+)$/.exec(ps(pid, "etime") ?? "");
|
|
104
|
-
if (!elapsed)
|
|
105
|
-
return undefined;
|
|
106
|
-
const [days, hours, minutes, seconds] = elapsed.slice(1).map((part) => Number(part ?? 0));
|
|
107
|
-
return Date.now() - (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;
|
|
108
|
-
}
|
|
109
113
|
function exists(pid) {
|
|
110
114
|
try {
|
|
111
115
|
process.kill(pid, 0);
|
|
@@ -355,8 +359,10 @@ export function createWork(options) {
|
|
|
355
359
|
store.save();
|
|
356
360
|
};
|
|
357
361
|
const needGas = async () => {
|
|
358
|
-
if ((await chain.ethBalance()) === 0n)
|
|
359
|
-
|
|
362
|
+
if ((await chain.ethBalance()) === 0n) {
|
|
363
|
+
const help = await options.gasHelp?.().catch(() => undefined);
|
|
364
|
+
throw new Error(`The wallet has no ETH to pay gas for the submit${help ? `. ${help}` : ""}. The task is tried again`);
|
|
365
|
+
}
|
|
360
366
|
};
|
|
361
367
|
const inTime = async (task) => {
|
|
362
368
|
if ((await chain.now()) >= task.deadline)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brainchain-worker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Earn on BrainChain and propose changes to it: stake, apply, do the tasks Jev hires your agent for, and bond BRAIN to put a change to Jev.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "BrainChain <hello@usebrainchain.com>",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"brainchain-verify": "^0.4.0",
|
|
32
|
-
"viem": "^2.
|
|
32
|
+
"viem": "^2.57.12"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/node": "^22.10.2",
|