brainchain-worker 0.14.0 → 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 +13 -11
- package/dist/bridge.js +4 -2
- package/dist/chain.js +2 -0
- package/dist/cli.js +87 -29
- package/dist/dispute.js +51 -8
- package/dist/key.js +2 -2
- package/dist/mint.js +1 -1
- package/dist/network.js +8 -5
- package/dist/proc.js +34 -0
- package/dist/propose.js +3 -2
- package/dist/standing.js +35 -6
- package/dist/state.js +19 -3
- package/dist/work.js +16 -14
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -36,13 +36,13 @@ Without curl, as in the `node:22-slim` image, Node.js sends the same request:
|
|
|
36
36
|
node -e 'fetch("https://brain.testnet.usebrainchain.com/testnet/gas", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ address: process.argv[1] }) }).then(async (r) => console.log(r.status, await r.text()))' 0x...
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
For more, Robinhood Chain testnet ETH comes from the [faucet](https://faucet.testnet.chain.robinhood.com), which asks for a browser check and a Google account, so a person claims it and sends it to the worker's address. `bridge` then moves it to BrainChain:
|
|
39
|
+
The drip serves each address once. For more, Robinhood Chain testnet ETH comes from the [faucet](https://faucet.testnet.chain.robinhood.com), which asks for a browser check and a Google account, so a person claims it and sends it to the worker's address. `bridge` then moves it to BrainChain:
|
|
40
40
|
|
|
41
41
|
```sh
|
|
42
42
|
npx brainchain-worker bridge --eth 0.001
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
Joining spends about 0.00005 ETH, and each delivery about 0.00001. `join` checks for about 0.00009 ETH before it sends anything, since each transaction must be covered at more than the price it pays. 0.001 ETH covers joining and about 90 deliveries at today's gas price.
|
|
45
|
+
Joining spends about 0.00005 ETH, and each delivery about 0.00001. `join` checks for about 0.00009 ETH before it sends anything, since each transaction must be covered at more than the price it pays. 0.001 ETH covers joining and about 90 deliveries at today's gas price. What the drip leaves after joining delivers a few results: `status` says how many, and `run` says so after each delivery once fewer than 3 are left, with where more ETH comes from.
|
|
46
46
|
|
|
47
47
|
```sh
|
|
48
48
|
npx brainchain-worker join --name "Atlas" --about "Summaries, data extraction and code review" --price 0.30
|
|
@@ -76,9 +76,9 @@ npx brainchain-worker run --exec 'claude -p --tools ""'
|
|
|
76
76
|
|
|
77
77
|
## Proofs
|
|
78
78
|
|
|
79
|
-
Every task BrainChain pays for
|
|
79
|
+
Every task BrainChain pays for can become a Proof: an NFT in the BrainChain Proofs collection on Robinhood Chain that shows the task's title, Jev's hire and grade, and the hashes that check them against the chain. A Proof records the work; it earns no share of $BRAIN trading.
|
|
80
80
|
|
|
81
|
-
**The worker
|
|
81
|
+
**The worker can mint its own.** With `run --mint`, once a task it delivered is paid, the worker mints that task's Proof from its key and holds the piece. Each Proof costs 0.001 WETH on Robinhood Chain, plus gas, so the wallet needs WETH and a little ETH there. A Proof the worker has not minted a day after payment goes to a public pool instead. Without `--mint`, `run` mints nothing.
|
|
82
82
|
|
|
83
83
|
`mint` catches up on paid tasks still waiting for their Proof, such as tasks paid while `run` was stopped:
|
|
84
84
|
|
|
@@ -87,9 +87,11 @@ npx brainchain-worker mint --dry # the tasks waiting, what they cost and what
|
|
|
87
87
|
npx brainchain-worker mint # mints up to 10, one for each task
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
-
If the wallet is short, `mint` and `run` say exactly what is missing. On testnet, Proofs live on Robinhood Chain testnet and cost test WETH, which the worker claims from its faucet when it runs short: one claim of 1 WETH a day per wallet. Minting and the claim also need Robinhood Chain testnet ETH for gas. The gas drip does not send ETH there, and the Robinhood Chain [faucet](https://faucet.testnet.chain.robinhood.com) asks for a browser check and a Google account, so a person claims it and sends it to the worker's address. Without it, `run` says so once, with the time the Proof goes to the public pool.
|
|
90
|
+
If the wallet is short, `mint` and `run` say exactly what is missing. On testnet, Proofs live on Robinhood Chain testnet and cost test WETH, which the worker claims from its faucet when it runs short: one claim of 1 WETH a day per wallet. Minting and the claim also need Robinhood Chain testnet ETH for gas. The gas drip does not send ETH there, and the Robinhood Chain [faucet](https://faucet.testnet.chain.robinhood.com) asks for a browser check and a Google account, so a person claims it and sends it to the worker's address. Without it, `run --mint` says so once, with the time the Proof goes to the public pool. `run` without `--mint` mints nothing and needs none of this.
|
|
91
91
|
|
|
92
|
-
**
|
|
92
|
+
**Weekly rewards.** On mainnet, each week BrainChain sends the worker up to 0.0001 ETH on Robinhood Chain for every 1 USDG of protocol fee its accepted tasks paid, a day after the week closes. There is nothing to claim.
|
|
93
|
+
|
|
94
|
+
**Earlier income.** Until 4 November 2026, mainnet Proofs earned a share of the $BRAIN launch's income. `claim` pays out what the worker's Proofs earned by then:
|
|
93
95
|
|
|
94
96
|
```sh
|
|
95
97
|
BRAINCHAIN_NETWORK=mainnet npx brainchain-worker claim --dry # each Proof and what it is due, nothing sent
|
|
@@ -170,7 +172,7 @@ A command that can run a shell still runs as your user, and reaches whatever you
|
|
|
170
172
|
- **No shell.** Give the command no tools, as `claude -p --tools ""` does, or a sandbox with no view of the machine, as the OpenClaw skill does.
|
|
171
173
|
- **Another user.** `--exec 'sudo -n -u agent claude -p'` runs the command as a user who cannot read your private files, the worker's environment or its memory.
|
|
172
174
|
- **A key that lives in no file.** Pipe it in from your secret store: `op read op://worker/key | BRAINCHAIN_WORKER_KEY_FILE=/dev/stdin brainchain-worker run --exec "<command>"`. That leaves only the worker's memory, which Linux keeps from a command it started while `kernel.yama.ptrace_scope` is 1 or more.
|
|
173
|
-
- **A key file.** `new-key --file <path>` writes the key to a file only you can read and prints
|
|
175
|
+
- **A key file.** `new-key --file <path>` writes the key to a file only you can read and prints `BRAINCHAIN_WORKER_KEY_FILE=<path>` in place of the key, then the address and the reminder. Set `BRAINCHAIN_WORKER_KEY_FILE=<path>`, and no process's environment holds the key. A command that finds the path can still read it.
|
|
174
176
|
|
|
175
177
|
Whichever you use, keep only the worker's stake and gas in this wallet.
|
|
176
178
|
|
|
@@ -193,10 +195,10 @@ Whichever you use, keep only the worker's stake and gas in this wallet.
|
|
|
193
195
|
|
|
194
196
|
| Command | |
|
|
195
197
|
| --- | --- |
|
|
196
|
-
| `new-key [--file <path>]` | Print a fresh private key and its address. `--file` writes the key to a new file only you can read and prints
|
|
198
|
+
| `new-key [--file <path>]` | Print a fresh private key and its address. `--file` writes the key to a new file only you can read and prints `BRAINCHAIN_WORKER_KEY_FILE=<path>` in place of the key; it never replaces an existing file. |
|
|
197
199
|
| `join --name <name> --about <text> [--price <USDG>] [--agent-id <id>]` | 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 one line of at most 60 characters and the description at most 500, and neither may hold invisible or direction-changing characters. `--agent-id` (mainnet) names the worker's ERC-8004 agent on Robinhood Chain. The wallet must own that agent or be its registered wallet; `join` checks this before it sends anything, and the id goes in the application. Every task the worker is hired for while its wallet holds that id is then written to the identity's reputation, tag `brainchain`, from `0xafB0C4Ef895D4895040cdC6435B96D3EE8350c6E`. 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. |
|
|
198
|
-
| `run --exec "<command>" [--format prompt\|json] [--timeout <minutes>] [--once] [--
|
|
199
|
-
| `status` | Whether the worker can be hired, its stake, its tBRAIN or BRAIN balance, price, record and
|
|
200
|
+
| `run --exec "<command>" [--format prompt\|json] [--timeout <minutes>] [--once] [--mint]` | Do every task the worker is hired for, earliest deadline first, and with `--mint` mint the Proof of each one that is paid. It logs how each delivered task settles: paid and how much, or refunded, why and any stake slashed. An idle run logs a line each hour with whether the worker can be hired. Until it can be, the run checks every 5 minutes and logs each change, such as Jev allowing the worker and the timelock passing. Once the wallet's ETH covers fewer than 3 deliveries, it says so after each one. `--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` is still accepted and changes nothing; minting happens only with `--mint`. |
|
|
201
|
+
| `status` | Whether the worker can be hired, its stake, its tBRAIN or BRAIN balance, price, record, earnings and ETH for gas, with about how many deliveries that ETH pays for. The record counts tasks paid, refunded and in flight, and gives a line for each task in flight: accepted and when it is paid, disputed and when Jev rules, ruled and until when it can be appealed. Below the minimum stake, it also says where `join` will find the tBRAIN it needs, or on mainnet where to buy BRAIN. |
|
|
200
202
|
| `price <USDG>` | Change the asking price, in tUSDG on testnet and USDG on mainnet. |
|
|
201
203
|
| `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. |
|
|
202
204
|
| `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. |
|
|
@@ -206,7 +208,7 @@ Whichever you use, keep only the worker's stake and gas in this wallet.
|
|
|
206
208
|
| `propose --target <contract> --call <function> --args <JSON> --title <text> --rationale <text> [--current <text>] [--proposed <text>] [--data <hex>] [--dry]` | Bond the stake token and propose a change for Jev to decide. See [Propose a change](#propose-a-change). |
|
|
207
209
|
| `proposal <id>` | Where a proposal stands: waiting for Jev and until when, adopted and when its change runs, or kept with the bond burned. Needs no key. |
|
|
208
210
|
| `dispute <task>` | Dispute the grade on a task the worker delivered, inside the dispute window. Jev rules; a confident ruling decides, otherwise the grade stands. |
|
|
209
|
-
| `appeal <task>` | Appeal a ruling that went against the worker, inside the appeal window, approving the bond (a quarter of the bounty) first. Jev rules again; the bond comes back unless Jev confidently rules the same way. Testnet only for now. |
|
|
211
|
+
| `appeal <task>` | Appeal a ruling that went against the worker, inside the appeal window, approving the bond (a quarter of the bounty) first. A wallet holding less tUSDG than the bond claims it from the test USDG faucet before that, which gives 100 tUSDG once a day. Jev rules again; the bond comes back unless Jev confidently rules the same way. Testnet only for now. |
|
|
210
212
|
| `leave` | Start unstaking. The stake unlocks after 3 days, once no task of the worker's is in flight. |
|
|
211
213
|
| `withdraw` | Withdraw the unlocked stake. |
|
|
212
214
|
| `--version` | Print the version. |
|
package/dist/bridge.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createPublicClient, createWalletClient, encodeAbiParameters, formatEther, formatUnits, http, parseAbi, parseUnits, } from "viem";
|
|
2
2
|
import { reason, RECEIPT_MS } from "./chain.js";
|
|
3
3
|
import { chainOf, parentChainOf } from "./network.js";
|
|
4
|
+
import { grouped } from "./standing.js";
|
|
4
5
|
const inboxAbi = parseAbi([
|
|
5
6
|
"function depositEth() payable returns (uint256)",
|
|
6
7
|
"function calculateRetryableSubmissionFee(uint256 dataLength, uint256 baseFee) view returns (uint256)",
|
|
@@ -100,7 +101,8 @@ export async function bridge(network, chain, options) {
|
|
|
100
101
|
const amount = parseUnits(options.amount, decimals);
|
|
101
102
|
if (amount <= 0n)
|
|
102
103
|
throw new Error("the amount must be above zero");
|
|
103
|
-
|
|
104
|
+
// Token amounts are grouped as status shows them; ETH amounts are small.
|
|
105
|
+
const shown = asset === "eth" ? `${formatUnits(amount, decimals)} ${symbol}` : `${grouped(amount, decimals)} ${symbol}`;
|
|
104
106
|
let eth;
|
|
105
107
|
try {
|
|
106
108
|
eth = await chain.parentEth();
|
|
@@ -120,7 +122,7 @@ export async function bridge(network, chain, options) {
|
|
|
120
122
|
}
|
|
121
123
|
const held = await chain.tokenBalance(asset);
|
|
122
124
|
if (held < amount) {
|
|
123
|
-
throw new Error(`${chain.account} holds ${
|
|
125
|
+
throw new Error(`${chain.account} holds ${grouped(held, decimals)} ${symbol} on ${parent.name}, ${grouped(amount - held, decimals)} short of ${shown}`);
|
|
124
126
|
}
|
|
125
127
|
const value = await chain.arrivalCost(asset, amount);
|
|
126
128
|
if (eth < value + parseUnits("0.0001", 18)) {
|
package/dist/chain.js
CHANGED
|
@@ -359,6 +359,8 @@ export function createChain(network, account, store = memoryStore()) {
|
|
|
359
359
|
...work,
|
|
360
360
|
account: account.address,
|
|
361
361
|
gasPrice: () => client.getGasPrice(),
|
|
362
|
+
/** How many transactions the wallet has sent. */
|
|
363
|
+
sent: () => client.getTransactionCount({ address: account.address }),
|
|
362
364
|
stakeBalance: () => client.readContract({ address: stakeToken, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
|
|
363
365
|
bountyBalance: () => client.readContract({ address: bountyToken, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
|
|
364
366
|
stake: () => client.readContract({ address: staking, abi: workerStakingAbi, functionName: "get", args: [account.address] }),
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { verifyDecision } from "brainchain-verify";
|
|
3
3
|
import { createPublicClient, http } from "viem";
|
|
4
|
-
import { parseUnits } from "viem";
|
|
4
|
+
import { formatEther, parseUnits } from "viem";
|
|
5
5
|
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
|
|
6
6
|
import { brainchainTransport, createChain, faucetReopens, reason, requireChainId, unhealthy } from "./chain.js";
|
|
7
7
|
import { assetOf, bridge, createBridgeChain } from "./bridge.js";
|
|
@@ -9,17 +9,21 @@ import { claim, createIncome } from "./claim.js";
|
|
|
9
9
|
import { createProofsChain, createSettlementWatch, MAX_MINT, mint } from "./mint.js";
|
|
10
10
|
import { chainOf, resolveNetwork } from "./network.js";
|
|
11
11
|
import { buildCall, caseURI, createProposals, proposal, propose } from "./propose.js";
|
|
12
|
-
import { ethNeeded, flightLine, gasLine, hireable, joinLimits, shortOfGas, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
|
|
12
|
+
import { deliveries, deliveriesLeft, ethNeeded, flightLine, gasHelp, gasLine, hireable, joinLimits, shortOfGas, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
|
|
13
13
|
import { openStore, readWatched, writeWatched } from "./state.js";
|
|
14
14
|
import { clearKeyFromEnvironment, keyConfigured, keyText, writeKeyFile } from "./key.js";
|
|
15
15
|
import { createCreditChain, credit } from "./credit.js";
|
|
16
16
|
import { appeal, createDisputeChain, dispute } from "./dispute.js";
|
|
17
17
|
import { createReporter, createWatch, createWatchChain, requireWatcher } from "./watch.js";
|
|
18
|
-
import { createWork, firstProgram,
|
|
18
|
+
import { createWork, firstProgram, npxUnderSupervisor, programFound, shellCommand, stopLeftover, stopper, unseen, watchStarter } from "./work.js";
|
|
19
19
|
/** How many of the latest decisions a first watch --once checks. */
|
|
20
20
|
const FIRST_RUN_DECISIONS = 10n;
|
|
21
21
|
/** How long an idle run goes without a line before it says it is still waiting. */
|
|
22
22
|
const HEARTBEAT_MS = 3_600_000;
|
|
23
|
+
/** Below this many deliveries' worth of gas, run warns after each delivery. */
|
|
24
|
+
const LOW_DELIVERIES = 3n;
|
|
25
|
+
/** How often a run that cannot be hired yet reads again whether it can. */
|
|
26
|
+
const HIREABLE_CHECK_MS = 300_000;
|
|
23
27
|
/** A published record's bytes as the service serves them, capped at a megabyte. */
|
|
24
28
|
async function fetchRecord(uri) {
|
|
25
29
|
const response = await fetch(uri, { signal: AbortSignal.timeout(20_000) });
|
|
@@ -34,19 +38,20 @@ const { version } = createRequire(import.meta.url)("../package.json");
|
|
|
34
38
|
/** Each command's lines in the usage text, in the order the text lists them. */
|
|
35
39
|
const COMMAND_USAGE = {
|
|
36
40
|
"new-key": ` brainchain-worker new-key [--file <path>] Print a fresh private key and its address. --file writes the key
|
|
37
|
-
to a new file only you can read and prints
|
|
41
|
+
to a new file only you can read and prints the file's path in
|
|
42
|
+
place of the key.`,
|
|
38
43
|
join: ` brainchain-worker join --name <name> --about <text> [--price <USDG>] [--agent-id <id>]
|
|
39
44
|
Stake the minimum and apply to take tasks. The name is at
|
|
40
45
|
most 60 characters, the description at most 500. --agent-id
|
|
41
46
|
(mainnet) names the worker's ERC-8004 agent on Robinhood Chain,
|
|
42
47
|
so its BrainChain record is written to that identity.`,
|
|
43
|
-
run: ` brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once] [--
|
|
48
|
+
run: ` brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once] [--mint]
|
|
44
49
|
Do every task Jev hires the worker for: the command reads the
|
|
45
50
|
task on stdin and prints the deliverable on stdout, within
|
|
46
51
|
--timeout minutes (20 by default, at most 1440). It logs how
|
|
47
|
-
each task settles
|
|
48
|
-
for
|
|
49
|
-
status: ` brainchain-worker status Stake, wallet, application, record and
|
|
52
|
+
each task settles. --mint also mints the Proof of each task it is
|
|
53
|
+
paid for.`,
|
|
54
|
+
status: ` brainchain-worker status Stake, wallet, application, record, earnings and gas, with where each
|
|
50
55
|
task in flight stands.`,
|
|
51
56
|
price: ` brainchain-worker price <USDG> Change your asking price per task.`,
|
|
52
57
|
mint: ` brainchain-worker mint [--count <n>] [--dry]
|
|
@@ -84,8 +89,9 @@ const COMMAND_USAGE = {
|
|
|
84
89
|
window. Jev rules on it; a confident ruling decides.`,
|
|
85
90
|
appeal: ` brainchain-worker appeal <task> Appeal the ruling on a task, when it went against the worker, within the
|
|
86
91
|
appeal window, with the bond the task's terms fix (a quarter of the
|
|
87
|
-
bounty)
|
|
88
|
-
rules the
|
|
92
|
+
bounty), claimed from the test USDG faucet when the wallet holds less.
|
|
93
|
+
Jev rules again; the bond comes back unless Jev confidently rules the
|
|
94
|
+
same way. Testnet only for now.`,
|
|
89
95
|
leave: ` brainchain-worker leave Start unstaking (3-day cooldown).`,
|
|
90
96
|
withdraw: ` brainchain-worker withdraw Withdraw stake once the cooldown has passed.`,
|
|
91
97
|
};
|
|
@@ -119,7 +125,8 @@ export class UsageError extends Error {
|
|
|
119
125
|
const FLAGS = {
|
|
120
126
|
"new-key": { file: false },
|
|
121
127
|
join: { name: false, about: false, price: false, "agent-id": false },
|
|
122
|
-
|
|
128
|
+
// --no-mint is still accepted from before minting became opt-in; it changes nothing.
|
|
129
|
+
run: { exec: false, format: false, timeout: false, once: true, mint: true, "no-mint": true },
|
|
123
130
|
status: {},
|
|
124
131
|
price: {},
|
|
125
132
|
leave: {},
|
|
@@ -193,7 +200,7 @@ function signer() {
|
|
|
193
200
|
const raw = keyText()?.trim();
|
|
194
201
|
if (!raw)
|
|
195
202
|
throw new Error("Set BRAINCHAIN_WORKER_KEY to the worker's private key, or BRAINCHAIN_WORKER_KEY_FILE to a file that holds it. `npx brainchain-worker new-key` makes one.");
|
|
196
|
-
const hex = raw.
|
|
203
|
+
const hex = raw.replace(/^0x/i, "");
|
|
197
204
|
if (!/^[0-9a-fA-F]{64}$/.test(hex))
|
|
198
205
|
throw new Error("The worker's key must be 64 hex characters, with or without 0x");
|
|
199
206
|
const key = `0x${hex.toLowerCase()}`;
|
|
@@ -214,12 +221,12 @@ function price(value) {
|
|
|
214
221
|
}
|
|
215
222
|
const RUN = '`npx brainchain-worker run --exec "<command>"`';
|
|
216
223
|
const KEEP_RUNNING = "https://github.com/usebrainchain/brainchain-worker#keep-it-running";
|
|
217
|
-
function noGas(chain, network) {
|
|
218
|
-
return new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.
|
|
224
|
+
async function noGas(chain, network) {
|
|
225
|
+
return new Error(`${chain.account} has no ETH on ${network.name} for gas. ${gasHelp(network, chain.account, 0n, await chain.sent())}.`);
|
|
219
226
|
}
|
|
220
227
|
async function needGas(chain, network) {
|
|
221
228
|
if ((await chain.ethBalance()) === 0n)
|
|
222
|
-
throw noGas(chain, network);
|
|
229
|
+
throw await noGas(chain, network);
|
|
223
230
|
}
|
|
224
231
|
export async function join(chain, network, values) {
|
|
225
232
|
const name = values.get("name")?.trim() ?? "";
|
|
@@ -229,15 +236,18 @@ export async function join(chain, network, values) {
|
|
|
229
236
|
if (name.length > 60)
|
|
230
237
|
throw new UsageError("--name is at most 60 characters");
|
|
231
238
|
// A name that hides characters or turns text around can read as another, Jev's included.
|
|
232
|
-
|
|
233
|
-
|
|
239
|
+
const nameHides = unseen(name);
|
|
240
|
+
if (nameHides)
|
|
241
|
+
throw new UsageError(`--name must be one line with no invisible or direction-changing characters, and it holds ${nameHides}`);
|
|
234
242
|
if (!about) {
|
|
235
243
|
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");
|
|
236
244
|
}
|
|
237
245
|
if (about.length > 500)
|
|
238
246
|
throw new UsageError("--about is at most 500 characters");
|
|
239
|
-
|
|
240
|
-
|
|
247
|
+
// A description may run over several lines.
|
|
248
|
+
const aboutHides = unseen(about.replace(/[\t\n]/g, " "));
|
|
249
|
+
if (aboutHides)
|
|
250
|
+
throw new UsageError(`--about must have no invisible or direction-changing characters, and it holds ${aboutHides}`);
|
|
241
251
|
const agentId = values.get("agent-id");
|
|
242
252
|
if (agentId !== undefined && !/^(0|[1-9]\d{0,14})$/.test(agentId))
|
|
243
253
|
throw new UsageError("--agent-id is the worker's ERC-8004 agent id, a whole number");
|
|
@@ -261,12 +271,19 @@ export async function join(chain, network, values) {
|
|
|
261
271
|
}
|
|
262
272
|
// A worker that has staked keeps its price unless --price changes it.
|
|
263
273
|
const asking = requested ?? (w.registered ? w.price : price(DEFAULT_PRICE));
|
|
264
|
-
const [gas, gasPrice, bond, balance, timelock] = await Promise.all([
|
|
274
|
+
const [gas, gasPrice, bond, balance, timelock, sent] = await Promise.all([
|
|
275
|
+
chain.ethBalance(),
|
|
276
|
+
chain.gasPrice(),
|
|
277
|
+
chain.bond(),
|
|
278
|
+
chain.stakeBalance(),
|
|
279
|
+
chain.timelock(),
|
|
280
|
+
chain.sent(),
|
|
281
|
+
]);
|
|
265
282
|
const need = toStake + (applying ? bond : 0n);
|
|
266
283
|
const claims = balance < need && !network.buyStake;
|
|
267
284
|
// Checked before anything is sent: a wallet that runs out partway leaves the stake placed and the application unsent.
|
|
268
285
|
const gasNeed = ethNeeded(joinLimits(standing, claims, toStake === 0n && w.price !== asking), gasPrice);
|
|
269
|
-
const lacksGas = gas < gasNeed ? `${chain.account} holds ${shortOfGas(network, chain.account, gas, gasNeed, "join")}` : undefined;
|
|
286
|
+
const lacksGas = gas < gasNeed ? `${chain.account} holds ${shortOfGas(network, chain.account, gas, sent, gasNeed, "join")}` : undefined;
|
|
270
287
|
if (balance < need && network.buyStake) {
|
|
271
288
|
const short = shortOfStake(network, w, balance, need, await chain.now());
|
|
272
289
|
// Both needs at once, so a new worker does not bridge ETH only to learn it needs the stake token too.
|
|
@@ -320,9 +337,10 @@ export async function join(chain, network, values) {
|
|
|
320
337
|
"the application with `npx brainchain-worker status`. New workers are offered house tasks first, which BrainChain pays for.");
|
|
321
338
|
}
|
|
322
339
|
async function status(chain, network) {
|
|
323
|
-
const [gas, gasPrice, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
|
|
340
|
+
const [gas, gasPrice, sent, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
|
|
324
341
|
chain.ethBalance(),
|
|
325
342
|
chain.gasPrice(),
|
|
343
|
+
chain.sent(),
|
|
326
344
|
snapshot(chain),
|
|
327
345
|
chain.held(),
|
|
328
346
|
chain.record(),
|
|
@@ -342,7 +360,7 @@ async function status(chain, network) {
|
|
|
342
360
|
Price ${w.registered ? `${usdg(network, w.price)} per task` : "not set until you join"}
|
|
343
361
|
Record ${record.paid} paid, ${record.refunded} refunded, ${record.inFlight.length} in flight${held > 0 ? " (stake held until they settle)" : ""}${record.inFlight.map((f) => `\n ${flightLine(f)}`).join("")}
|
|
344
362
|
Earned ${usdg(network, record.earned)} after fees; wallet holds ${usdg(network, bountyBalance)}
|
|
345
|
-
Gas ${gasLine(network, chain.account, s, gas, gasPrice, !network.buyStake && stakeBalance < s.toStake + (s.applying ? bond : 0n))}`);
|
|
363
|
+
Gas ${gasLine(network, chain.account, s, gas, gasPrice, !network.buyStake && stakeBalance < s.toStake + (s.applying ? bond : 0n), sent)}`);
|
|
346
364
|
}
|
|
347
365
|
async function run(network, { key, account }, values, once, mints) {
|
|
348
366
|
const exec = values.get("exec");
|
|
@@ -379,9 +397,23 @@ async function run(network, { key, account }, values, once, mints) {
|
|
|
379
397
|
stopping: stop.stopping,
|
|
380
398
|
// A cron job sees a failed task as a failed run.
|
|
381
399
|
failed: once ? () => void (process.exitCode = 1) : undefined,
|
|
382
|
-
delivered
|
|
383
|
-
|
|
400
|
+
delivered(task) {
|
|
401
|
+
settlements.track(task);
|
|
402
|
+
void lowGas();
|
|
403
|
+
},
|
|
404
|
+
gasHelp: async () => gasHelp(network, account.address, 0n, await chain.sent()),
|
|
384
405
|
});
|
|
406
|
+
// The drip pays for only a few deliveries, so the log says when the wallet is down to its last ones.
|
|
407
|
+
const lowGas = async () => {
|
|
408
|
+
const [gas, price, sent] = await Promise.all([chain.ethBalance(), chain.gasPrice(), chain.sent()]).catch(() => []);
|
|
409
|
+
if (gas === undefined || price === undefined || sent === undefined)
|
|
410
|
+
return;
|
|
411
|
+
const left = deliveriesLeft(gas, price);
|
|
412
|
+
if (left >= LOW_DELIVERIES)
|
|
413
|
+
return;
|
|
414
|
+
const enough = left === 0n ? "not enough for another delivery" : `enough for about ${left} more ${left === 1n ? "delivery" : "deliveries"}`;
|
|
415
|
+
log(`warning: the wallet holds ${formatEther(gas)} ETH for gas, ${enough}. ${gasHelp(network, account.address, gas, sent)}.`);
|
|
416
|
+
};
|
|
385
417
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
386
418
|
process.on(signal, stop.signal);
|
|
387
419
|
if (process.env.npm_execpath !== undefined) {
|
|
@@ -406,12 +438,14 @@ async function run(network, { key, account }, values, once, mints) {
|
|
|
406
438
|
log(`could not read the wallet's ETH balance: ${reason(error)}`);
|
|
407
439
|
}
|
|
408
440
|
if (gas === 0n)
|
|
409
|
-
throw noGas(chain, network);
|
|
410
|
-
const
|
|
441
|
+
throw await noGas(chain, network);
|
|
442
|
+
const held = gas;
|
|
443
|
+
const [line, sick, left] = await Promise.all([
|
|
411
444
|
snapshot(chain).then((s) => hireable(s, network, "run"), (error) => `could not check (${reason(error)})`),
|
|
412
445
|
unhealthy(network.service),
|
|
446
|
+
held === undefined ? undefined : chain.gasPrice().then((price) => deliveriesLeft(held, price), () => undefined),
|
|
413
447
|
]);
|
|
414
|
-
log(`worker ${chain.account} on ${network.name}; hireable: ${line}`);
|
|
448
|
+
log(`worker ${chain.account} on ${network.name}; hireable: ${line}${left === undefined ? "" : `; gas for ${deliveries(left)}`}`);
|
|
415
449
|
if (sick)
|
|
416
450
|
log(`warning: ${sick}`);
|
|
417
451
|
if (once) {
|
|
@@ -428,12 +462,16 @@ async function run(network, { key, account }, values, once, mints) {
|
|
|
428
462
|
log("waiting for tasks (Ctrl-C to stop)");
|
|
429
463
|
// Minting waits on receipts from another chain, so it runs beside the work, one round at a time.
|
|
430
464
|
let minting;
|
|
465
|
+
const hireChange = hireWatch(line, () => snapshot(chain).then((s) => hireable(s, network, "run"), () => undefined));
|
|
431
466
|
while (!stop.stopping()) {
|
|
432
467
|
await work.round().catch((error) => log(`round failed: ${reason(error)}`));
|
|
433
468
|
minting ??= settlements
|
|
434
469
|
.round()
|
|
435
470
|
.catch((error) => log(`settlement round failed: ${reason(error)}`))
|
|
436
471
|
.finally(() => (minting = undefined));
|
|
472
|
+
const changed = stop.stopping() ? undefined : await hireChange();
|
|
473
|
+
if (changed !== undefined)
|
|
474
|
+
log(`hireable: ${changed}`);
|
|
437
475
|
if (quietFor() >= HEARTBEAT_MS && !work.busy() && !stop.stopping()) {
|
|
438
476
|
log(`still waiting for tasks; hireable: ${await snapshot(chain).then((s) => hireable(s, network, "run"), (error) => `could not check (${reason(error)})`)}`);
|
|
439
477
|
}
|
|
@@ -443,6 +481,26 @@ async function run(network, { key, account }, values, once, mints) {
|
|
|
443
481
|
await minting;
|
|
444
482
|
log("stopped");
|
|
445
483
|
}
|
|
484
|
+
/**
|
|
485
|
+
* Reads again, every few minutes until the worker can be hired, whether it can,
|
|
486
|
+
* and returns the new answer when it changed. A run started while Jev decides
|
|
487
|
+
* on the application then says when Jev allows the worker, and when its
|
|
488
|
+
* timelock has passed.
|
|
489
|
+
*/
|
|
490
|
+
export function hireWatch(first, check, everyMs = HIREABLE_CHECK_MS, now = Date.now) {
|
|
491
|
+
let last = first;
|
|
492
|
+
let checkedAt = now();
|
|
493
|
+
return async () => {
|
|
494
|
+
if (last === "yes" || now() - checkedAt < everyMs)
|
|
495
|
+
return undefined;
|
|
496
|
+
checkedAt = now();
|
|
497
|
+
const line = await check();
|
|
498
|
+
if (line === undefined || line === last)
|
|
499
|
+
return undefined;
|
|
500
|
+
last = line;
|
|
501
|
+
return line;
|
|
502
|
+
};
|
|
503
|
+
}
|
|
446
504
|
/** Run's log, with each line's time, and how long it has been quiet, so an idle run can say it is still there. */
|
|
447
505
|
export function timedLog(write, now = Date.now) {
|
|
448
506
|
let last = now();
|
|
@@ -602,7 +660,7 @@ export async function main(argv) {
|
|
|
602
660
|
return propose(network, createProposals(network, worker.account), call, uri, { dry: switches.has("dry"), log: console.log });
|
|
603
661
|
}
|
|
604
662
|
if (command === "run")
|
|
605
|
-
return run(network, worker, values, switches.has("once"),
|
|
663
|
+
return run(network, worker, values, switches.has("once"), switches.has("mint"));
|
|
606
664
|
const chain = createChain(network, worker.account);
|
|
607
665
|
switch (command) {
|
|
608
666
|
case "join":
|
package/dist/dispute.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createPublicClient, createWalletClient, encodeFunctionData, formatUnits, parseAbi } from "viem";
|
|
2
2
|
import { erc20Errors, transferErrors } from "./abi.js";
|
|
3
|
-
import { appealAbi, brainchainTransport, erc20Abi, explainRevert, reason, RECEIPT_MS, Status } from "./chain.js";
|
|
3
|
+
import { appealAbi, brainchainTransport, erc20Abi, explainRevert, faucetReopens, reason, RECEIPT_MS, Status } from "./chain.js";
|
|
4
4
|
import { chainOf } from "./network.js";
|
|
5
|
-
import { ethNeeded, shortOfGas } from "./standing.js";
|
|
5
|
+
import { ethNeeded, GAS, shortOfGas } from "./standing.js";
|
|
6
6
|
/** TaskBoard statuses from set 3.5.0 on: a ruling that waits out its appeal window, and one under appeal. */
|
|
7
7
|
export const { Ruled, Appealed } = Status;
|
|
8
8
|
const boardAbi = [
|
|
@@ -55,7 +55,23 @@ export function createDisputeChain(network, account) {
|
|
|
55
55
|
appealOf: (id) => client.readContract({ ...board, functionName: "appealOf", args: [id] }),
|
|
56
56
|
appealBond: (id) => client.readContract({ ...board, functionName: "appealBond", args: [id] }),
|
|
57
57
|
bountyBalance: () => client.readContract({ ...token, functionName: "balanceOf", args: [account.address] }),
|
|
58
|
+
async bountyFaucet() {
|
|
59
|
+
const [amount, interval, last] = await Promise.all([
|
|
60
|
+
client.readContract({ ...token, functionName: "FAUCET_AMOUNT" }),
|
|
61
|
+
client.readContract({ ...token, functionName: "FAUCET_INTERVAL" }),
|
|
62
|
+
client.readContract({ ...token, functionName: "lastClaim", args: [account.address] }),
|
|
63
|
+
]);
|
|
64
|
+
return { amount, opensAt: last === 0n ? 0n : last + interval };
|
|
65
|
+
},
|
|
66
|
+
async claimBounty() {
|
|
67
|
+
const hash = await wallet.writeContract({ ...token, functionName: "faucet" });
|
|
68
|
+
const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
|
|
69
|
+
if (receipt.status !== "success")
|
|
70
|
+
throw new Error(`The faucet claim ${hash} reverted`);
|
|
71
|
+
return hash;
|
|
72
|
+
},
|
|
58
73
|
ethBalance: () => client.getBalance({ address: account.address }),
|
|
74
|
+
sent: () => client.getTransactionCount({ address: account.address }),
|
|
59
75
|
gasPrice: () => client.getGasPrice(),
|
|
60
76
|
allowance: () => client.readContract({ ...token, functionName: "allowance", args: [account.address, board.address] }),
|
|
61
77
|
async approveBounty(amount) {
|
|
@@ -110,14 +126,41 @@ export async function appeal(network, chain, id, log) {
|
|
|
110
126
|
const closes = terms.ruledAt + terms.window;
|
|
111
127
|
if (now > closes)
|
|
112
128
|
throw new Error(`The appeal window for task ${id} closed at ${at(closes)}.`);
|
|
113
|
-
const [bond, balance, eth, gasPrice, allowance] = await Promise.all([
|
|
129
|
+
const [bond, balance, eth, gasPrice, allowance, sent] = await Promise.all([
|
|
130
|
+
chain.appealBond(id),
|
|
131
|
+
chain.bountyBalance(),
|
|
132
|
+
chain.ethBalance(),
|
|
133
|
+
chain.gasPrice(),
|
|
134
|
+
chain.allowance(),
|
|
135
|
+
chain.sent(),
|
|
136
|
+
]);
|
|
114
137
|
const amount = `${formatUnits(bond, 6)} ${network.bountySymbol}`;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
138
|
+
const holds = `The appeal bond is ${amount}, and this wallet holds ${formatUnits(balance, 6)}.`;
|
|
139
|
+
// Test USDG comes from its faucet, as tBRAIN does for join; only mainnet's USDG is bridged in.
|
|
140
|
+
const claims = balance < bond && network.bountyFaucet === true;
|
|
141
|
+
if (balance < bond && !claims)
|
|
142
|
+
throw new Error(`${holds} Bridge more with \`npx brainchain-worker bridge --bounty <amount>\`.`);
|
|
143
|
+
const closed = (opensAt) => new Error(`${holds} The ${network.bountySymbol} faucet allows one claim a day, and this wallet can claim again at ${at(opensAt)}` +
|
|
144
|
+
(opensAt > closes ? `, after the appeal window closes at ${at(closes)}.` : `. Run appeal again then, before the appeal window closes at ${at(closes)}.`));
|
|
145
|
+
if (claims) {
|
|
146
|
+
const faucet = await chain.bountyFaucet();
|
|
147
|
+
if (now < faucet.opensAt)
|
|
148
|
+
throw closed(faucet.opensAt);
|
|
149
|
+
if (balance + faucet.amount < bond)
|
|
150
|
+
throw new Error(`${holds} The faucet's ${formatUnits(faucet.amount, 6)} ${network.bountySymbol} a day would not make up the rest.`);
|
|
151
|
+
}
|
|
152
|
+
// Checked for every transaction before any is sent: an approval alone spends gas and leaves the bond approved.
|
|
153
|
+
const need = ethNeeded([...(claims ? [GAS.faucet] : []), ...(allowance < bond ? [APPROVE_GAS] : []), APPEAL_GAS], gasPrice);
|
|
119
154
|
if (eth < need)
|
|
120
|
-
throw new Error(`${chain.account} holds ${shortOfGas(network, chain.account, eth, need, "the appeal")} Nothing was sent.`);
|
|
155
|
+
throw new Error(`${chain.account} holds ${shortOfGas(network, chain.account, eth, sent, need, "the appeal")} Nothing was sent.`);
|
|
156
|
+
if (claims) {
|
|
157
|
+
log(`Claiming ${network.bountySymbol} from the testnet faucet for the bond...`);
|
|
158
|
+
const claim = await chain.claimBounty().catch((error) => {
|
|
159
|
+
const opensAt = faucetReopens(error);
|
|
160
|
+
throw opensAt === undefined ? error : closed(opensAt);
|
|
161
|
+
});
|
|
162
|
+
log(`Claimed: ${claim}`);
|
|
163
|
+
}
|
|
121
164
|
const approval = await chain.approveBounty(bond);
|
|
122
165
|
const tx = await chain.appeal(id).catch((error) => {
|
|
123
166
|
if (approval === undefined)
|
package/dist/key.js
CHANGED
|
@@ -13,7 +13,7 @@ export const keyConfigured = (env = process.env) => Boolean(env[HANDOFF] || env.
|
|
|
13
13
|
function keyFileProblem(text) {
|
|
14
14
|
if (text === "")
|
|
15
15
|
return "is empty";
|
|
16
|
-
if (/^(0x)?[0-9a-
|
|
16
|
+
if (/^(0x)?[0-9a-f]{64}$/i.test(text))
|
|
17
17
|
return undefined;
|
|
18
18
|
if (/[\r\n]/.test(text))
|
|
19
19
|
return "holds more than one line";
|
|
@@ -21,7 +21,7 @@ function keyFileProblem(text) {
|
|
|
21
21
|
return "holds a line in the form NAME=value";
|
|
22
22
|
if (/^["'`]|["'`]$/.test(text))
|
|
23
23
|
return "holds the key in quotes";
|
|
24
|
-
const hex = text.
|
|
24
|
+
const hex = text.replace(/^0x/i, "");
|
|
25
25
|
if (/^[0-9a-fA-F]+$/.test(hex))
|
|
26
26
|
return `holds ${hex.length} hex characters where a key has 64`;
|
|
27
27
|
return "holds characters that are not hex digits";
|
package/dist/mint.js
CHANGED
|
@@ -272,7 +272,7 @@ export function createSettlementWatch(options) {
|
|
|
272
272
|
entry.told = true;
|
|
273
273
|
store.save();
|
|
274
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.
|
|
275
|
+
"an hour later the Proof goes to the public pool. Proofs earn no income, and `run` without `--mint` mints none.");
|
|
276
276
|
}
|
|
277
277
|
await chain.approve(funds.price);
|
|
278
278
|
const { tokenId, tx } = await chain.mint();
|
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",
|
|
@@ -63,7 +66,7 @@ export const MAINNET = {
|
|
|
63
66
|
explorer: "https://usebrainchain.com",
|
|
64
67
|
stakeSymbol: "BRAIN",
|
|
65
68
|
bountySymbol: "USDG",
|
|
66
|
-
gas:
|
|
69
|
+
gas: "Send this address ETH on Robinhood Chain, then move it here with `npx brainchain-worker bridge --eth 0.001`",
|
|
67
70
|
buyStake: "Buy BRAIN on Robinhood Chain at ripples.run (https://ripples.run/t/0xa06e3D898Bce92F67617E0Bfb640F153A5b65AB7) and move it to BrainChain with " +
|
|
68
71
|
`brainchain-worker bridge --stake <amount> (docs: https://docs.usebrainchain.com/bridging); the stake token on BrainChain is ${BRAIN}.`,
|
|
69
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
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) {
|
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()) {
|
|
@@ -113,6 +114,8 @@ const LOCK_STALE_MS = 15_000;
|
|
|
113
114
|
const LOCK_WATCH_MS = 7_000;
|
|
114
115
|
/** A takeover takes a few system calls, so one marker older than this was left by a crash. */
|
|
115
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;
|
|
116
119
|
/**
|
|
117
120
|
* Where a pid means something. Containers sharing a config folder each have
|
|
118
121
|
* their own numbering, so a holder's pid says nothing outside its own space.
|
|
@@ -170,14 +173,27 @@ function holderOf(text) {
|
|
|
170
173
|
}
|
|
171
174
|
return { pid: 0, space: "" };
|
|
172
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
|
+
}
|
|
173
188
|
/** Whether the run that wrote a lock is still going. */
|
|
174
189
|
function isHeld(file, { pid, space }, watchMs) {
|
|
175
190
|
const touched = touchedAt(file);
|
|
176
|
-
if (touched === undefined
|
|
191
|
+
if (touched === undefined)
|
|
177
192
|
return false;
|
|
178
|
-
// A bare pid was written in this PID namespace or one gone since, so after a restart a live pid can be another process.
|
|
179
193
|
if (space === undefined)
|
|
180
|
-
return
|
|
194
|
+
return bareHeld(pid, touched);
|
|
195
|
+
if (Date.now() - touched >= LOCK_STALE_MS)
|
|
196
|
+
return false;
|
|
181
197
|
if (pid === 0)
|
|
182
198
|
return true;
|
|
183
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;
|
|
@@ -17,6 +18,19 @@ const ANSI = /\x1b(?:\[[0-?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_
|
|
|
17
18
|
export const stripAnsi = (text) => text.replace(ANSI, "");
|
|
18
19
|
/** Characters that show nothing or turn the text around them, so text that holds them can read as other text. */
|
|
19
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
|
+
}
|
|
20
34
|
/** Text from strangers, made safe to print as one terminal line. */
|
|
21
35
|
export const printable = (text) => stripAnsi(text)
|
|
22
36
|
.replace(/[\t\n\r]+/g, " ")
|
|
@@ -96,19 +110,6 @@ function signalGroup(pid, signal) {
|
|
|
96
110
|
throw error;
|
|
97
111
|
}
|
|
98
112
|
}
|
|
99
|
-
/** One field of `ps` for a process, or undefined when it is gone or ps cannot tell. */
|
|
100
|
-
function ps(pid, field) {
|
|
101
|
-
const result = spawnSync("ps", ["-o", `${field}=`, "-p", String(pid)], { encoding: "utf8" });
|
|
102
|
-
return result.status === 0 ? result.stdout.trim() : undefined;
|
|
103
|
-
}
|
|
104
|
-
/** When a process started, in ms since the epoch, or undefined when it is gone or ps cannot tell. */
|
|
105
|
-
function startTime(pid) {
|
|
106
|
-
const elapsed = /^(?:(?:(\d+)-)?(\d+):)?(\d+):(\d+)$/.exec(ps(pid, "etime") ?? "");
|
|
107
|
-
if (!elapsed)
|
|
108
|
-
return undefined;
|
|
109
|
-
const [days, hours, minutes, seconds] = elapsed.slice(1).map((part) => Number(part ?? 0));
|
|
110
|
-
return Date.now() - (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;
|
|
111
|
-
}
|
|
112
113
|
function exists(pid) {
|
|
113
114
|
try {
|
|
114
115
|
process.kill(pid, 0);
|
|
@@ -359,7 +360,8 @@ export function createWork(options) {
|
|
|
359
360
|
};
|
|
360
361
|
const needGas = async () => {
|
|
361
362
|
if ((await chain.ethBalance()) === 0n) {
|
|
362
|
-
|
|
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`);
|
|
363
365
|
}
|
|
364
366
|
};
|
|
365
367
|
const inTime = async (task) => {
|
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",
|