brainchain-worker 0.4.0 → 0.4.2
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 +4 -4
- package/dist/claim.js +4 -4
- package/dist/cli.js +409 -0
- package/dist/index.js +2 -370
- package/dist/mint.js +3 -3
- package/dist/network.js +11 -2
- package/dist/propose.js +318 -0
- package/dist/standing.js +10 -3
- package/dist/state.js +10 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -109,10 +109,10 @@ Briefs are written by strangers, and some will try to get your agent to do somet
|
|
|
109
109
|
| Command | |
|
|
110
110
|
| --- | --- |
|
|
111
111
|
| `new-key` | Print a fresh private key and its address. |
|
|
112
|
-
| `join --name <name> --about <text> [--price <
|
|
112
|
+
| `join --name <name> --about <text> [--price <USDG>]` | Claim tBRAIN from the faucet if needed (on mainnet, the BRAIN comes from the wallet), stake the minimum and apply. Jev reads the name and description to decide, and refuses an empty one. The name is at most 60 characters and the description at most 500. A new worker's price defaults to 0.20; a worker that has staked keeps its price unless `--price` changes it. An allowed worker whose stake fell below the minimum, after `leave` or a slash, stakes back up to it. Stake that is unstaking does not count, so the tBRAIN comes from the wallet or the faucet until `withdraw` returns it. |
|
|
113
113
|
| `run --exec "<command>" [--format prompt\|json] [--timeout <minutes>] [--once] [--no-mint]` | Do every task the worker is hired for, earliest deadline first, and mint the Proof of each one that is paid. `--timeout` sets how many minutes the command may spend on one task: 20 by default, at most 1440, fractions allowed. `--once` does one pass and exits, with code 1 if a task failed or was left too close to its deadline, for a cron job. `--no-mint` mints no Proofs. |
|
|
114
|
-
| `status` | Whether the worker can be hired, its stake, its tBRAIN balance, price, record and earnings. Below the minimum stake, it also says where `join` will find the tBRAIN it needs. |
|
|
115
|
-
| `price <
|
|
114
|
+
| `status` | Whether the worker can be hired, its stake, its tBRAIN or BRAIN balance, price, record and earnings. Below the minimum stake, it also says where `join` will find the tBRAIN it needs, or on mainnet where to buy BRAIN. |
|
|
115
|
+
| `price <USDG>` | Change the asking price, in tUSDG on testnet and USDG on mainnet. |
|
|
116
116
|
| `mint [--count <n>] [--dry]` | Mint the Proof of each paid task waiting for one, up to 10 at a time or `--count`, at 0.001 WETH each on Robinhood Chain plus gas. `--dry` lists the tasks and what the wallet lacks without sending anything. |
|
|
117
117
|
| `claim [--dry]` | Claim the ETH due on the worker's Proofs on Robinhood Chain, in one transaction. `--dry` lists each Proof and what it is due without sending anything. |
|
|
118
118
|
| `leave` | Start unstaking. The stake unlocks after 3 days, once no task of the worker's is in flight. |
|
|
@@ -131,7 +131,7 @@ Briefs are written by strangers, and some will try to get your agent to do somet
|
|
|
131
131
|
## Price, pay and stake
|
|
132
132
|
|
|
133
133
|
- **Price.** Jev sees each worker's asking price, record and description when it hires, so a lower price can win a task. The worker is paid the task's bounty either way.
|
|
134
|
-
- **Pay.** Bounties are paid
|
|
134
|
+
- **Pay.** Bounties are paid to the worker's wallet in tUSDG, or in USDG on mainnet. House tasks pay 1 tUSDG.
|
|
135
135
|
- **Stake.** The stake is held while the worker has a task in flight. A worker can dispute a grade by calling `dispute(taskId)` on the task board; if it loses a dispute it opened, 20% of its stake is burned.
|
|
136
136
|
|
|
137
137
|
## Links
|
package/dist/claim.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { createPublicClient, createWalletClient, formatEther,
|
|
1
|
+
import { createPublicClient, createWalletClient, formatEther, parseAbi, parseEventLogs } from "viem";
|
|
2
2
|
import { RECEIPT_MS } from "./chain.js";
|
|
3
|
-
import { proofsChainOf } from "./network.js";
|
|
3
|
+
import { proofsChainOf, proofsTransport } from "./network.js";
|
|
4
4
|
export const proofsIncomeAbi = parseAbi([
|
|
5
5
|
"function due(uint256 tokenId) view returns (uint256)",
|
|
6
6
|
"function claimMany(uint256[] tokenIds)",
|
|
@@ -12,7 +12,7 @@ export const proofsIncomeAbi = parseAbi([
|
|
|
12
12
|
export const MAX_CLAIM = 200;
|
|
13
13
|
export function createIncome(proofs, income, account) {
|
|
14
14
|
const chain = proofsChainOf(proofs);
|
|
15
|
-
const transport =
|
|
15
|
+
const transport = proofsTransport(proofs);
|
|
16
16
|
const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
|
|
17
17
|
const wallet = createWalletClient({ account, chain, transport });
|
|
18
18
|
const router = { address: income, abi: proofsIncomeAbi };
|
|
@@ -66,7 +66,7 @@ export async function claim(network, connect, options) {
|
|
|
66
66
|
const { log } = options;
|
|
67
67
|
const { income, name } = network.proofs;
|
|
68
68
|
if (income === undefined) {
|
|
69
|
-
return log(`Proofs on ${
|
|
69
|
+
return log(`Proofs on ${name} earn no income, so there is nothing to claim. Mainnet Proofs are claimed with BRAINCHAIN_NETWORK=mainnet.`);
|
|
70
70
|
}
|
|
71
71
|
const chain = connect(income);
|
|
72
72
|
const ids = await ownedProofs(network, chain.account, options.fetcher);
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { formatEther, parseUnits } from "viem";
|
|
3
|
+
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
|
|
4
|
+
import { createChain, faucetReopens, reason, unhealthy } from "./chain.js";
|
|
5
|
+
import { claim, createIncome } from "./claim.js";
|
|
6
|
+
import { createProofMinter, createProofsChain, MAX_MINT, mint } from "./mint.js";
|
|
7
|
+
import { resolveNetwork } from "./network.js";
|
|
8
|
+
import { buildCall, caseURI, createProposals, proposal, propose } from "./propose.js";
|
|
9
|
+
import { hireable, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
|
|
10
|
+
import { openStore } from "./state.js";
|
|
11
|
+
import { createWork, shellCommand, stopLeftover, stopper } from "./work.js";
|
|
12
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
13
|
+
const USAGE = `brainchain-worker ${version}
|
|
14
|
+
|
|
15
|
+
Earn on BrainChain by doing tasks: stake, apply, and let Jev hire your agent.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
brainchain-worker new-key Print a fresh private key and its address.
|
|
19
|
+
brainchain-worker join --name <name> --about <text> [--price <USDG>]
|
|
20
|
+
Stake the minimum and apply to take tasks. The name is at
|
|
21
|
+
most 60 characters, the description at most 500.
|
|
22
|
+
brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once] [--no-mint]
|
|
23
|
+
Do every task Jev hires the worker for: the command reads the
|
|
24
|
+
task on stdin and prints the deliverable on stdout, within
|
|
25
|
+
--timeout minutes (20 by default, at most 1440). The worker
|
|
26
|
+
mints the Proof of each task it is paid for, unless --no-mint.
|
|
27
|
+
brainchain-worker status Stake, wallet, application, record and earnings.
|
|
28
|
+
brainchain-worker price <USDG> Change your asking price per task.
|
|
29
|
+
brainchain-worker mint [--count <n>] [--dry]
|
|
30
|
+
Mint a Proof for each paid task waiting for one, up to 10 or
|
|
31
|
+
--count, at 0.001 WETH each on Robinhood Chain. --dry sends nothing.
|
|
32
|
+
brainchain-worker claim [--dry] Claim the ETH due on the worker's Proofs, on Robinhood Chain.
|
|
33
|
+
--dry lists what is due and sends nothing.
|
|
34
|
+
brainchain-worker propose --target board|executor|registry --call <function> --args '<JSON array>'
|
|
35
|
+
--title <text> --rationale <text> [--current <text>] [--proposed <text>] [--dry]
|
|
36
|
+
Bond the stake token and propose a change to BrainChain for Jev to
|
|
37
|
+
decide. --data <hex> gives the call as raw calldata instead of
|
|
38
|
+
--call and --args. --dry checks the call and sends nothing.
|
|
39
|
+
brainchain-worker proposal <id> Where a proposal stands: waiting, adopted and when it runs, or kept.
|
|
40
|
+
brainchain-worker leave Start unstaking (3-day cooldown).
|
|
41
|
+
brainchain-worker withdraw Withdraw stake once the cooldown has passed.
|
|
42
|
+
brainchain-worker --version Print the version.
|
|
43
|
+
|
|
44
|
+
Environment:
|
|
45
|
+
BRAINCHAIN_WORKER_KEY Private key of the worker's wallet, 64 hex characters with or without 0x.
|
|
46
|
+
BRAINCHAIN_NETWORK testnet (the default) or mainnet.
|
|
47
|
+
BRAINCHAIN_RPC_URL Overrides the network's RPC.
|
|
48
|
+
BRAINCHAIN_SERVICE_URL Overrides the BrainChain service that keeps results: https, or http on
|
|
49
|
+
localhost or 127.0.0.1.
|
|
50
|
+
|
|
51
|
+
The command sees the worker's environment without any BRAINCHAIN_ variable, plus BRAINCHAIN_TASK_ID.
|
|
52
|
+
Unfinished work is kept in ~/.config/brainchain-worker/<address>.<network>.json across restarts,
|
|
53
|
+
and one run at a time works for each address on each network.
|
|
54
|
+
|
|
55
|
+
Docs: https://docs.usebrainchain.com/run-a-worker`;
|
|
56
|
+
/** A mistake in the command line. Every other error is printed after the program's name. */
|
|
57
|
+
export class UsageError extends Error {
|
|
58
|
+
}
|
|
59
|
+
/** The flags each command takes; `true` marks a switch, which takes no value. */
|
|
60
|
+
const FLAGS = {
|
|
61
|
+
join: { name: false, about: false, price: false },
|
|
62
|
+
run: { exec: false, format: false, timeout: false, once: true, "no-mint": true },
|
|
63
|
+
status: {},
|
|
64
|
+
price: {},
|
|
65
|
+
leave: {},
|
|
66
|
+
withdraw: {},
|
|
67
|
+
claim: { dry: true },
|
|
68
|
+
mint: { count: false, dry: true },
|
|
69
|
+
propose: { target: false, call: false, args: false, data: false, title: false, rationale: false, current: false, proposed: false, dry: true },
|
|
70
|
+
proposal: {},
|
|
71
|
+
};
|
|
72
|
+
const POSITIONAL = { price: 1, proposal: 1 };
|
|
73
|
+
export function parse(command, args) {
|
|
74
|
+
const known = FLAGS[command];
|
|
75
|
+
if (known === undefined)
|
|
76
|
+
throw new UsageError(`Unknown command "${command}" (see brainchain-worker --help)`);
|
|
77
|
+
const positional = [];
|
|
78
|
+
const values = new Map();
|
|
79
|
+
const switches = new Set();
|
|
80
|
+
for (let i = 0; i < args.length; i++) {
|
|
81
|
+
const arg = args[i];
|
|
82
|
+
if (!arg.startsWith("--")) {
|
|
83
|
+
positional.push(arg);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const [name, inline] = arg.slice(2).split(/=(.*)/s, 2);
|
|
87
|
+
const isSwitch = known[name];
|
|
88
|
+
if (isSwitch === undefined)
|
|
89
|
+
throw new UsageError(`The ${command} command has no --${name} flag (see brainchain-worker --help)`);
|
|
90
|
+
if (isSwitch) {
|
|
91
|
+
if (inline !== undefined)
|
|
92
|
+
throw new UsageError(`--${name} takes no value`);
|
|
93
|
+
switches.add(name);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const value = inline ?? args[++i];
|
|
97
|
+
if (value === undefined || (inline === undefined && value.startsWith("--")))
|
|
98
|
+
throw new UsageError(`--${name} needs a value`);
|
|
99
|
+
values.set(name, value);
|
|
100
|
+
}
|
|
101
|
+
if (positional.length > (POSITIONAL[command] ?? 0))
|
|
102
|
+
throw new UsageError(`Unexpected "${positional.at(-1)}" (see brainchain-worker --help)`);
|
|
103
|
+
return { positional, values, switches };
|
|
104
|
+
}
|
|
105
|
+
function signer() {
|
|
106
|
+
const raw = process.env.BRAINCHAIN_WORKER_KEY?.trim();
|
|
107
|
+
if (!raw)
|
|
108
|
+
throw new Error("Set BRAINCHAIN_WORKER_KEY to the worker's private key. `npx brainchain-worker new-key` makes one.");
|
|
109
|
+
const hex = raw.startsWith("0x") ? raw.slice(2) : raw;
|
|
110
|
+
if (!/^[0-9a-fA-F]{64}$/.test(hex))
|
|
111
|
+
throw new Error("BRAINCHAIN_WORKER_KEY must be 64 hex characters, with or without 0x");
|
|
112
|
+
const key = `0x${hex.toLowerCase()}`;
|
|
113
|
+
try {
|
|
114
|
+
return { key, account: privateKeyToAccount(key) };
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
throw new Error("BRAINCHAIN_WORKER_KEY is not a valid private key. `npx brainchain-worker new-key` makes one.");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const DEFAULT_PRICE = "0.20";
|
|
121
|
+
/** A price in the bounty token, which has 6 decimals. */
|
|
122
|
+
function price(value) {
|
|
123
|
+
if (!/^\d{1,6}(\.\d{1,6})?$/.test(value)) {
|
|
124
|
+
throw new UsageError(`The price must be a number from 0 to 999999 with at most 6 decimals, such as 0.20, not "${value}"`);
|
|
125
|
+
}
|
|
126
|
+
return parseUnits(value, 6);
|
|
127
|
+
}
|
|
128
|
+
const RUN = '`npx brainchain-worker run --exec "<command>"`';
|
|
129
|
+
function noGas(chain, network) {
|
|
130
|
+
return new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas}; 0.001 ETH is plenty.`);
|
|
131
|
+
}
|
|
132
|
+
async function needGas(chain, network) {
|
|
133
|
+
if ((await chain.ethBalance()) === 0n)
|
|
134
|
+
throw noGas(chain, network);
|
|
135
|
+
}
|
|
136
|
+
export async function join(chain, network, values) {
|
|
137
|
+
const name = values.get("name")?.trim() ?? "";
|
|
138
|
+
const about = values.get("about")?.trim() ?? "";
|
|
139
|
+
if (!name)
|
|
140
|
+
throw new UsageError("--name is required, up to 60 characters");
|
|
141
|
+
if (name.length > 60)
|
|
142
|
+
throw new UsageError("--name is at most 60 characters");
|
|
143
|
+
if (!about) {
|
|
144
|
+
throw new UsageError("--about is required: say what work your agent does in up to 500 characters, since Jev reads it to decide and refuses an empty application");
|
|
145
|
+
}
|
|
146
|
+
if (about.length > 500)
|
|
147
|
+
throw new UsageError("--about is at most 500 characters");
|
|
148
|
+
const requested = values.has("price") ? price(values.get("price")) : undefined;
|
|
149
|
+
const gas = await chain.ethBalance();
|
|
150
|
+
// The faucet covers the stake where there is one, so gas is all a new worker can lack there.
|
|
151
|
+
if (gas === 0n && !network.buyStake)
|
|
152
|
+
throw noGas(chain, network);
|
|
153
|
+
const { allowed, w, change, pending, open, applying, toStake } = await snapshot(chain);
|
|
154
|
+
// A worker that has staked keeps its price unless --price changes it.
|
|
155
|
+
const asking = requested ?? (w.registered ? w.price : price(DEFAULT_PRICE));
|
|
156
|
+
const [bond, balance, timelock] = await Promise.all([chain.bond(), chain.stakeBalance(), chain.timelock()]);
|
|
157
|
+
const need = toStake + (applying ? bond : 0n);
|
|
158
|
+
if (balance < need && network.buyStake) {
|
|
159
|
+
const short = shortOfStake(network, w, balance, need, await chain.now());
|
|
160
|
+
// Both needs at once, so a new worker does not bridge ETH only to learn it needs the stake token too.
|
|
161
|
+
throw new Error(gas === 0n ? `${noGas(chain, network).message}\n${short}` : short);
|
|
162
|
+
}
|
|
163
|
+
if (gas === 0n)
|
|
164
|
+
throw noGas(chain, network);
|
|
165
|
+
if (balance < need) {
|
|
166
|
+
const [faucet, now] = await Promise.all([chain.faucet(), chain.now()]);
|
|
167
|
+
const closed = (opensAt) => {
|
|
168
|
+
const withdraw = now >= w.unlocksAt ? "run withdraw now" : `run withdraw after ${when(w.unlocksAt)}`;
|
|
169
|
+
const unstaking = w.unstaking > 0n ? `, or ${withdraw} and join with the ${stake(network, w.unstaking)} it returns` : "";
|
|
170
|
+
return new Error(`The wallet holds ${stake(network, balance)} of the ${stake(network, need)} join needs, and the faucet allows one claim a day: ` +
|
|
171
|
+
`this wallet can claim again at ${when(opensAt)}. Run join again then${unstaking}.`);
|
|
172
|
+
};
|
|
173
|
+
if (now < faucet.opensAt)
|
|
174
|
+
throw closed(faucet.opensAt);
|
|
175
|
+
console.log(`Claiming ${network.stakeSymbol} from the testnet faucet...`);
|
|
176
|
+
await chain.claimStakeToken().catch((error) => {
|
|
177
|
+
const opensAt = faucetReopens(error);
|
|
178
|
+
throw opensAt === undefined ? error : closed(opensAt);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
if (toStake > 0n) {
|
|
182
|
+
console.log(`Staking ${stake(network, toStake)}${w.staked > 0n ? " more, up to the minimum," : ""} at ${usdg(network, asking)} per task...`);
|
|
183
|
+
await chain.approve(network.contracts.stakeToken, network.contracts.staking, toStake);
|
|
184
|
+
await chain.stakeTokens(toStake, asking);
|
|
185
|
+
}
|
|
186
|
+
else if (w.price !== asking) {
|
|
187
|
+
console.log(`Setting your price to ${usdg(network, asking)} per task...`);
|
|
188
|
+
await chain.setPrice(asking);
|
|
189
|
+
}
|
|
190
|
+
if (allowed) {
|
|
191
|
+
console.log(`${toStake > 0n ? "Staked back to the minimum, so this worker can be hired again." : "This worker can already be hired."} Start it with ${RUN}.`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (change && pending) {
|
|
195
|
+
console.log(`Jev already allowed this worker. It can be hired after ${when(change.eta)}; keep ${RUN} going until then.`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (open) {
|
|
199
|
+
console.log(`Application ${open.id} is already waiting for Jev.`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
console.log(`Applying with a ${stake(network, bond)} bond, returned if Jev allows the worker...`);
|
|
203
|
+
await chain.approve(network.contracts.stakeToken, network.contracts.bonds, bond);
|
|
204
|
+
const profile = Buffer.from(JSON.stringify({ name, about })).toString("base64");
|
|
205
|
+
await chain.apply(`data:application/json;base64,${profile}`);
|
|
206
|
+
console.log(`\nApplied. Jev usually decides within a few minutes. If it allows the worker, it can be hired once a ${Number(timelock) / 3600}-hour timelock passes.\n` +
|
|
207
|
+
`Start ${RUN} now so it takes its first task as soon as it is hired, and follow\n` +
|
|
208
|
+
"the application with `npx brainchain-worker status`. New workers are offered house tasks first, which BrainChain pays for.");
|
|
209
|
+
}
|
|
210
|
+
async function status(chain, network) {
|
|
211
|
+
const [gas, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
|
|
212
|
+
chain.ethBalance(),
|
|
213
|
+
snapshot(chain),
|
|
214
|
+
chain.held(),
|
|
215
|
+
chain.record(),
|
|
216
|
+
chain.bountyBalance(),
|
|
217
|
+
chain.stakeBalance(),
|
|
218
|
+
chain.bond(),
|
|
219
|
+
// A stake token without a faucet, as on mainnet, leaves out what the faucet would give.
|
|
220
|
+
network.buyStake ? undefined : chain.faucet().catch(() => undefined),
|
|
221
|
+
chain.now(),
|
|
222
|
+
]);
|
|
223
|
+
const { w, min } = s;
|
|
224
|
+
const unstaking = w.unstaking > 0n ? `, ${stake(network, w.unstaking)} unstaking until ${when(w.unlocksAt)}` : "";
|
|
225
|
+
console.log(`Worker ${chain.account} on ${network.name}
|
|
226
|
+
Hireable ${hireable(s, network, "status")}
|
|
227
|
+
Stake ${stake(network, w.staked)} (minimum ${stake(network, min)})${unstaking}
|
|
228
|
+
Wallet ${wallet(network, s, stakeBalance, bond, faucet, now)}
|
|
229
|
+
Price ${w.registered ? `${usdg(network, w.price)} per task` : "not set until you join"}
|
|
230
|
+
Record ${record.accepted} accepted, ${record.refunded} refunded, ${record.inFlight} in flight${held > 0 ? " (stake held until they settle)" : ""}
|
|
231
|
+
Earned ${usdg(network, record.earned)} after fees; wallet holds ${usdg(network, bountyBalance)}
|
|
232
|
+
Gas ${formatEther(gas)} ETH`);
|
|
233
|
+
}
|
|
234
|
+
async function run(network, { key, account }, values, once, mints) {
|
|
235
|
+
const exec = values.get("exec");
|
|
236
|
+
if (!exec)
|
|
237
|
+
throw new UsageError(`--exec is required: the command that does the work, such as --exec 'claude -p --tools ""'`);
|
|
238
|
+
const format = values.get("format") ?? "prompt";
|
|
239
|
+
if (format !== "prompt" && format !== "json")
|
|
240
|
+
throw new UsageError("--format is prompt or json");
|
|
241
|
+
const minutes = values.get("timeout") ?? "20";
|
|
242
|
+
if (!/^\d{1,4}(\.\d+)?$/.test(minutes) || Number(minutes) <= 0 || Number(minutes) > 1440) {
|
|
243
|
+
throw new UsageError("--timeout is the most minutes the command may spend on one task: more than 0, at most 1440, and 20 by default");
|
|
244
|
+
}
|
|
245
|
+
const log = (message) => console.log(`${new Date().toISOString().slice(0, 19)}Z ${message}`);
|
|
246
|
+
const store = openStore(account.address, network, log);
|
|
247
|
+
// The lock rules out a live run, so a command still in the state was left by one that died.
|
|
248
|
+
await stopLeftover(store, log);
|
|
249
|
+
const chain = createChain(network, account, store);
|
|
250
|
+
const proofs = createProofMinter({ network, chain: createProofsChain(network.proofs, account), settled: chain.settled, store, log, enabled: mints });
|
|
251
|
+
const command = shellCommand(exec);
|
|
252
|
+
// An idle stop keeps the exit code of a task that failed earlier in a --once pass.
|
|
253
|
+
const stop = stopper({ busy: () => work.busy(), kill: command.kill, log, exit: (code) => process.exit(code || process.exitCode) });
|
|
254
|
+
const work = createWork({
|
|
255
|
+
chain,
|
|
256
|
+
command,
|
|
257
|
+
format,
|
|
258
|
+
log,
|
|
259
|
+
timeoutMs: Number(minutes) * 60_000,
|
|
260
|
+
secrets: [key, key.slice(2)],
|
|
261
|
+
store,
|
|
262
|
+
stopping: stop.stopping,
|
|
263
|
+
// A cron job sees a failed task as a failed run.
|
|
264
|
+
failed: once ? () => void (process.exitCode = 1) : undefined,
|
|
265
|
+
delivered: proofs.track,
|
|
266
|
+
});
|
|
267
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
268
|
+
process.on(signal, stop.signal);
|
|
269
|
+
let gas;
|
|
270
|
+
try {
|
|
271
|
+
gas = await chain.ethBalance();
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
// The rest of a single pass reads the same RPC, so it would only fail the same way twice more.
|
|
275
|
+
if (once) {
|
|
276
|
+
log(`could not read the chain: ${reason(error)}`);
|
|
277
|
+
process.exitCode = 1;
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
log(`could not read the wallet's ETH balance: ${reason(error)}`);
|
|
281
|
+
}
|
|
282
|
+
if (gas === 0n)
|
|
283
|
+
throw noGas(chain, network);
|
|
284
|
+
const [line, sick] = await Promise.all([
|
|
285
|
+
snapshot(chain).then((s) => hireable(s, network, "run"), (error) => `could not check (${reason(error)})`),
|
|
286
|
+
unhealthy(network.service),
|
|
287
|
+
]);
|
|
288
|
+
log(`worker ${chain.account} on ${network.name}; hireable: ${line}`);
|
|
289
|
+
if (sick)
|
|
290
|
+
log(`warning: ${sick}`);
|
|
291
|
+
if (once) {
|
|
292
|
+
const result = await work.round().catch((error) => {
|
|
293
|
+
log(`round failed: ${reason(error)}`);
|
|
294
|
+
process.exitCode = 1;
|
|
295
|
+
return undefined;
|
|
296
|
+
});
|
|
297
|
+
if (result?.hired === 0 && result.delivered === 0)
|
|
298
|
+
log("no task is hired to this worker right now");
|
|
299
|
+
await proofs.round().catch((error) => log(`proofs round failed: ${reason(error)}`));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
log("waiting for tasks (Ctrl-C to stop)");
|
|
303
|
+
// Minting waits on receipts from another chain, so it runs beside the work, one round at a time.
|
|
304
|
+
let minting;
|
|
305
|
+
while (!stop.stopping()) {
|
|
306
|
+
await work.round().catch((error) => log(`round failed: ${reason(error)}`));
|
|
307
|
+
minting ??= proofs
|
|
308
|
+
.round()
|
|
309
|
+
.catch((error) => log(`proofs round failed: ${reason(error)}`))
|
|
310
|
+
.finally(() => (minting = undefined));
|
|
311
|
+
for (let i = 0; i < 15 && !stop.stopping(); i++)
|
|
312
|
+
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
313
|
+
}
|
|
314
|
+
await minting;
|
|
315
|
+
log("stopped");
|
|
316
|
+
}
|
|
317
|
+
export async function main(argv) {
|
|
318
|
+
const [command, ...rest] = argv;
|
|
319
|
+
if (command === undefined || command === "help" || argv.includes("--help") || argv.includes("-h")) {
|
|
320
|
+
console.log(USAGE);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (command === "--version" || command === "-v") {
|
|
324
|
+
console.log(version);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (command === "new-key") {
|
|
328
|
+
const fresh = generatePrivateKey();
|
|
329
|
+
console.log(`BRAINCHAIN_WORKER_KEY=${fresh}\naddress ${privateKeyToAccount(fresh).address}\nKeep the key secret; it controls the stake and the earnings.`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const { positional, values, switches } = parse(command, rest);
|
|
333
|
+
const network = resolveNetwork(process.env);
|
|
334
|
+
if (command === "claim") {
|
|
335
|
+
return claim(network, (income) => createIncome(network.proofs, income, signer().account), { dry: switches.has("dry"), log: console.log });
|
|
336
|
+
}
|
|
337
|
+
if (command === "mint") {
|
|
338
|
+
const count = values.get("count");
|
|
339
|
+
if (count !== undefined && !(/^\d{1,2}$/.test(count) && Number(count) >= 1 && Number(count) <= MAX_MINT)) {
|
|
340
|
+
throw new UsageError(`--count is how many Proofs to mint: 1 to ${MAX_MINT}`);
|
|
341
|
+
}
|
|
342
|
+
return mint(network, createProofsChain(network.proofs, signer().account), { count: count === undefined ? undefined : Number(count), dry: switches.has("dry"), log: console.log });
|
|
343
|
+
}
|
|
344
|
+
if (command === "proposal") {
|
|
345
|
+
const id = positional[0];
|
|
346
|
+
if (id === undefined || !/^\d{1,18}$/.test(id) || id === "0")
|
|
347
|
+
throw new UsageError("Usage: brainchain-worker proposal <id>, such as proposal 2");
|
|
348
|
+
// Reading a proposal needs no key, so any address stands in for the signer.
|
|
349
|
+
const reader = process.env.BRAINCHAIN_WORKER_KEY?.trim() ? signer().account : privateKeyToAccount(generatePrivateKey());
|
|
350
|
+
return proposal(network, createProposals(network, reader), BigInt(id), console.log);
|
|
351
|
+
}
|
|
352
|
+
const worker = signer();
|
|
353
|
+
if (command === "propose") {
|
|
354
|
+
if (!values.has("target"))
|
|
355
|
+
throw new UsageError("--target is required: board, executor or registry");
|
|
356
|
+
const call = buildCall(network, worker.account.address, {
|
|
357
|
+
target: values.get("target"),
|
|
358
|
+
call: values.get("call"),
|
|
359
|
+
args: values.get("args"),
|
|
360
|
+
data: values.get("data"),
|
|
361
|
+
});
|
|
362
|
+
const uri = caseURI(call, { title: values.get("title") ?? "", rationale: values.get("rationale") ?? "", current: values.get("current"), proposed: values.get("proposed") });
|
|
363
|
+
return propose(network, createProposals(network, worker.account), call, uri, { dry: switches.has("dry"), log: console.log });
|
|
364
|
+
}
|
|
365
|
+
if (command === "run")
|
|
366
|
+
return run(network, worker, values, switches.has("once"), !switches.has("no-mint"));
|
|
367
|
+
const chain = createChain(network, worker.account);
|
|
368
|
+
switch (command) {
|
|
369
|
+
case "join":
|
|
370
|
+
return join(chain, network, values);
|
|
371
|
+
case "status":
|
|
372
|
+
return status(chain, network);
|
|
373
|
+
case "price": {
|
|
374
|
+
if (positional[0] === undefined)
|
|
375
|
+
throw new UsageError("Usage: brainchain-worker price <USDG>, such as price 0.25");
|
|
376
|
+
const asking = price(positional[0]);
|
|
377
|
+
if (!(await chain.stake()).registered)
|
|
378
|
+
throw new Error("This worker has not staked yet. Run join, which sets the price too.");
|
|
379
|
+
await needGas(chain, network);
|
|
380
|
+
await chain.setPrice(asking);
|
|
381
|
+
console.log(`Price set to ${usdg(network, asking)} per task.`);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
case "leave": {
|
|
385
|
+
const w = await chain.stake();
|
|
386
|
+
if (w.staked === 0n) {
|
|
387
|
+
throw new Error(w.unstaking > 0n ? `Nothing left to unstake: ${stake(network, w.unstaking)} is already unstaking until ${when(w.unlocksAt)}.` : "Nothing is staked.");
|
|
388
|
+
}
|
|
389
|
+
await needGas(chain, network);
|
|
390
|
+
await chain.requestUnstake(w.staked);
|
|
391
|
+
const after = await chain.stake();
|
|
392
|
+
console.log(`Unstaking ${stake(network, w.staked)}. Withdraw with \`npx brainchain-worker withdraw\` after ${when(after.unlocksAt)}, once no task of the worker's is in flight.`);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
case "withdraw": {
|
|
396
|
+
const [w, held, now] = await Promise.all([chain.stake(), chain.held(), chain.now()]);
|
|
397
|
+
if (w.unstaking === 0n)
|
|
398
|
+
throw new Error("Nothing is unstaking. Run leave first.");
|
|
399
|
+
if (now < w.unlocksAt)
|
|
400
|
+
throw new Error(`${stake(network, w.unstaking)} unlocks at ${when(w.unlocksAt)}. Withdraw after that.`);
|
|
401
|
+
if (held > 0)
|
|
402
|
+
throw new Error(`The stake is held until the worker's ${held} task${held === 1 ? "" : "s"} in flight settle.`);
|
|
403
|
+
await needGas(chain, network);
|
|
404
|
+
await chain.withdraw();
|
|
405
|
+
console.log(`Withdrew ${stake(network, w.unstaking)}.`);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|