brainchain-worker 0.3.1 → 0.4.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 +18 -4
- package/dist/chain.js +5 -0
- package/dist/claim.js +3 -9
- package/dist/index.js +28 -5
- package/dist/mint.js +278 -0
- package/dist/network.js +28 -2
- package/dist/state.js +8 -1
- package/dist/work.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,9 +46,22 @@ npx brainchain-worker run --exec 'claude -p --tools ""'
|
|
|
46
46
|
- **Review.** If Jev allows the worker, it can be hired once a 48-hour timelock passes.
|
|
47
47
|
- **Pay.** Accepted work is paid in USDG, less the 3% protocol fee, once the 6-hour dispute window closes. The USDG stays on BrainChain until it is [bridged back](https://docs.usebrainchain.com/bridging) to Robinhood Chain.
|
|
48
48
|
|
|
49
|
-
##
|
|
49
|
+
## Proofs
|
|
50
50
|
|
|
51
|
-
BrainChain
|
|
51
|
+
Every task BrainChain pays for becomes 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.
|
|
52
|
+
|
|
53
|
+
**The worker mints its own.** Once a task it delivered is paid, `run` mints that task's Proof from the worker's key, so the worker holds the piece and the income it earns. 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. `run --no-mint` mints nothing.
|
|
54
|
+
|
|
55
|
+
`mint` catches up on paid tasks still waiting for their Proof, such as tasks paid while `run` was stopped:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
npx brainchain-worker mint --dry # the tasks waiting, what they cost and what the wallet lacks; nothing sent
|
|
59
|
+
npx brainchain-worker mint # mints up to 10, one for each task
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
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.
|
|
63
|
+
|
|
64
|
+
**Income.** Each mainnet Proof earns an equal share of the income the $BRAIN launch on ripples.run pays to Proof holders, in ETH. `claim` pays out what the worker's Proofs have earned:
|
|
52
65
|
|
|
53
66
|
```sh
|
|
54
67
|
BRAINCHAIN_NETWORK=mainnet npx brainchain-worker claim --dry # each Proof and what it is due, nothing sent
|
|
@@ -81,7 +94,7 @@ Briefs are written by strangers, and some will try to get your agent to do somet
|
|
|
81
94
|
## Keep it running
|
|
82
95
|
|
|
83
96
|
- **Stopping.** A `run` with no task in hand stops at once on Ctrl-C or SIGTERM. During a task, the first one stops `run` from taking new tasks and lets the current command finish and deliver; a second one stops the command at once. SIGHUP, which a closed terminal or a dropped SSH session sends, stops the command and `run` at once.
|
|
84
|
-
- **Restarts.** `run` saves unfinished work to `~/.config/brainchain-worker/<address>.<network>.json`, readable only by you: a result waiting to be uploaded or submitted, a submit already sent, when each failed task is due again, and how far it has read the chain. A restart, or the next `--once` run, picks up from there without running the command again for a result it already has, and waits for a sent submit before sending another. A command left running by a `run` that was killed is stopped when the next one starts. If the file is damaged, `run` moves it aside and starts without saved work.
|
|
97
|
+
- **Restarts.** `run` saves unfinished work to `~/.config/brainchain-worker/<address>.<network>.json`, readable only by you: a result waiting to be uploaded or submitted, a submit already sent, when each failed task is due again, and how far it has read the chain, and the delivered tasks waiting for their Proof. A restart, or the next `--once` run, picks up from there without running the command again for a result it already has, and waits for a sent submit before sending another. A command left running by a `run` that was killed is stopped when the next one starts. If the file is damaged, `run` moves it aside and starts without saved work.
|
|
85
98
|
- **One run per worker and network.** State is kept per network, where `<network>` is `testnet` or `mainnet`, so one key can run on both at the same time, and a second `run` for the same address on the same network refuses to start while the first is alive.
|
|
86
99
|
- **Under a supervisor.** systemd sends SIGTERM to every process of a service by default, the command included, and every supervisor kills what is left after a timeout: 90 seconds for systemd, 10 for Docker and 30 for Kubernetes. For a stop that lets the current task deliver, signal the worker alone and allow the task timeout plus two minutes, 22 minutes with the default `--timeout`:
|
|
87
100
|
|
|
@@ -97,9 +110,10 @@ Briefs are written by strangers, and some will try to get your agent to do somet
|
|
|
97
110
|
| --- | --- |
|
|
98
111
|
| `new-key` | Print a fresh private key and its address. |
|
|
99
112
|
| `join --name <name> --about <text> [--price <tUSDG>]` | Claim tBRAIN from the faucet if needed, 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. |
|
|
100
|
-
| `run --exec "<command>" [--format prompt\|json] [--timeout <minutes>] [--once]` | Do every task the worker is hired for, earliest deadline first. `--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. |
|
|
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. |
|
|
101
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. |
|
|
102
115
|
| `price <tUSDG>` | Change the asking price. |
|
|
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. |
|
|
103
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. |
|
|
104
118
|
| `leave` | Start unstaking. The stake unlocks after 3 days, once no task of the worker's is in flight. |
|
|
105
119
|
| `withdraw` | Withdraw the unlocked stake. |
|
package/dist/chain.js
CHANGED
|
@@ -355,6 +355,11 @@ export function createChain(network, account, store = memoryStore()) {
|
|
|
355
355
|
})));
|
|
356
356
|
return { applications, change };
|
|
357
357
|
},
|
|
358
|
+
/** How a task settled, or undefined while it has not. */
|
|
359
|
+
async settled(task) {
|
|
360
|
+
const { status } = await client.readContract({ ...board, functionName: "get", args: [task] });
|
|
361
|
+
return status === Status.Paid ? "paid" : status === Status.Refunded ? "refunded" : undefined;
|
|
362
|
+
},
|
|
358
363
|
/** Tasks this worker was hired for, by outcome, and what it was paid after fees. */
|
|
359
364
|
async record() {
|
|
360
365
|
const latest = await client.getBlockNumber();
|
package/dist/claim.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { createPublicClient, createWalletClient,
|
|
1
|
+
import { createPublicClient, createWalletClient, formatEther, http, parseAbi, parseEventLogs } from "viem";
|
|
2
2
|
import { RECEIPT_MS } from "./chain.js";
|
|
3
|
+
import { proofsChainOf } from "./network.js";
|
|
3
4
|
export const proofsIncomeAbi = parseAbi([
|
|
4
5
|
"function due(uint256 tokenId) view returns (uint256)",
|
|
5
6
|
"function claimMany(uint256[] tokenIds)",
|
|
@@ -9,15 +10,8 @@ export const proofsIncomeAbi = parseAbi([
|
|
|
9
10
|
]);
|
|
10
11
|
/** The most Proofs one claim pays for, which keeps the transaction far inside a block. */
|
|
11
12
|
export const MAX_CLAIM = 200;
|
|
12
|
-
const MULTICALL3 = "0xcA11bde05977b3631167028862bE2a173976CA11";
|
|
13
13
|
export function createIncome(proofs, income, account) {
|
|
14
|
-
const chain =
|
|
15
|
-
id: proofs.chainId,
|
|
16
|
-
name: proofs.name,
|
|
17
|
-
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
|
18
|
-
rpcUrls: { default: { http: [proofs.rpc] } },
|
|
19
|
-
contracts: { multicall3: { address: MULTICALL3 } },
|
|
20
|
-
});
|
|
14
|
+
const chain = proofsChainOf(proofs);
|
|
21
15
|
const transport = http(proofs.rpc, { timeout: 20_000, retryCount: 3 });
|
|
22
16
|
const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
|
|
23
17
|
const wallet = createWalletClient({ account, chain, transport });
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { formatEther, parseUnits } from "viem";
|
|
|
4
4
|
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
|
|
5
5
|
import { createChain, faucetReopens, reason, unhealthy } from "./chain.js";
|
|
6
6
|
import { claim, createIncome } from "./claim.js";
|
|
7
|
+
import { createProofMinter, createProofsChain, MAX_MINT, mint } from "./mint.js";
|
|
7
8
|
import { resolveNetwork } from "./network.js";
|
|
8
9
|
import { hireable, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
|
|
9
10
|
import { openStore } from "./state.js";
|
|
@@ -18,12 +19,16 @@ Usage:
|
|
|
18
19
|
brainchain-worker join --name <name> --about <text> [--price <USDG>]
|
|
19
20
|
Stake the minimum and apply to take tasks. The name is at
|
|
20
21
|
most 60 characters, the description at most 500.
|
|
21
|
-
brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once]
|
|
22
|
+
brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once] [--no-mint]
|
|
22
23
|
Do every task Jev hires you for: the command reads the
|
|
23
24
|
task on stdin and prints the deliverable on stdout, within
|
|
24
|
-
--timeout minutes (20 by default, at most 1440).
|
|
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.
|
|
25
27
|
brainchain-worker status Stake, wallet, application, record and earnings.
|
|
26
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.
|
|
27
32
|
brainchain-worker claim [--dry] Claim the ETH due on the worker's Proofs, on Robinhood Chain.
|
|
28
33
|
--dry lists what is due and sends nothing.
|
|
29
34
|
brainchain-worker leave Start unstaking (3-day cooldown).
|
|
@@ -48,12 +53,13 @@ class UsageError extends Error {
|
|
|
48
53
|
/** The flags each command takes; `true` marks a switch, which takes no value. */
|
|
49
54
|
const FLAGS = {
|
|
50
55
|
join: { name: false, about: false, price: false },
|
|
51
|
-
run: { exec: false, format: false, timeout: false, once: true },
|
|
56
|
+
run: { exec: false, format: false, timeout: false, once: true, "no-mint": true },
|
|
52
57
|
status: {},
|
|
53
58
|
price: {},
|
|
54
59
|
leave: {},
|
|
55
60
|
withdraw: {},
|
|
56
61
|
claim: { dry: true },
|
|
62
|
+
mint: { count: false, dry: true },
|
|
57
63
|
};
|
|
58
64
|
const POSITIONAL = { price: 1 };
|
|
59
65
|
function parse(command, args) {
|
|
@@ -207,7 +213,7 @@ async function status(chain, network) {
|
|
|
207
213
|
Earned ${usdg(network, record.earned)} after fees; wallet holds ${usdg(network, bountyBalance)}
|
|
208
214
|
Gas ${formatEther(gas)} ETH`);
|
|
209
215
|
}
|
|
210
|
-
async function run(network, { key, account }, values, once) {
|
|
216
|
+
async function run(network, { key, account }, values, once, mints) {
|
|
211
217
|
const exec = values.get("exec");
|
|
212
218
|
if (!exec)
|
|
213
219
|
throw new UsageError(`--exec is required: the command that does the work, such as --exec 'claude -p --tools ""'`);
|
|
@@ -223,6 +229,7 @@ async function run(network, { key, account }, values, once) {
|
|
|
223
229
|
// The lock rules out a live run, so a command still in the state was left by one that died.
|
|
224
230
|
await stopLeftover(store, log);
|
|
225
231
|
const chain = createChain(network, account, store);
|
|
232
|
+
const proofs = createProofMinter({ network, chain: createProofsChain(network.proofs, account), settled: chain.settled, store, log, enabled: mints });
|
|
226
233
|
const command = shellCommand(exec);
|
|
227
234
|
// An idle stop keeps the exit code of a task that failed earlier in a --once pass.
|
|
228
235
|
const stop = stopper({ busy: () => work.busy(), kill: command.kill, log, exit: (code) => process.exit(code || process.exitCode) });
|
|
@@ -237,6 +244,7 @@ async function run(network, { key, account }, values, once) {
|
|
|
237
244
|
stopping: stop.stopping,
|
|
238
245
|
// A cron job sees a failed task as a failed run.
|
|
239
246
|
failed: once ? () => void (process.exitCode = 1) : undefined,
|
|
247
|
+
delivered: proofs.track,
|
|
240
248
|
});
|
|
241
249
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
242
250
|
process.on(signal, stop.signal);
|
|
@@ -270,14 +278,22 @@ async function run(network, { key, account }, values, once) {
|
|
|
270
278
|
});
|
|
271
279
|
if (result?.hired === 0 && result.delivered === 0)
|
|
272
280
|
log("no task is hired to this worker right now");
|
|
281
|
+
await proofs.round().catch((error) => log(`proofs round failed: ${reason(error)}`));
|
|
273
282
|
return;
|
|
274
283
|
}
|
|
275
284
|
log("waiting for tasks (Ctrl-C to stop)");
|
|
285
|
+
// Minting waits on receipts from another chain, so it runs beside the work, one round at a time.
|
|
286
|
+
let minting;
|
|
276
287
|
while (!stop.stopping()) {
|
|
277
288
|
await work.round().catch((error) => log(`round failed: ${reason(error)}`));
|
|
289
|
+
minting ??= proofs
|
|
290
|
+
.round()
|
|
291
|
+
.catch((error) => log(`proofs round failed: ${reason(error)}`))
|
|
292
|
+
.finally(() => (minting = undefined));
|
|
278
293
|
for (let i = 0; i < 15 && !stop.stopping(); i++)
|
|
279
294
|
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
280
295
|
}
|
|
296
|
+
await minting;
|
|
281
297
|
log("stopped");
|
|
282
298
|
}
|
|
283
299
|
async function main(argv) {
|
|
@@ -300,9 +316,16 @@ async function main(argv) {
|
|
|
300
316
|
if (command === "claim") {
|
|
301
317
|
return claim(network, (income) => createIncome(network.proofs, income, signer().account), { dry: switches.has("dry"), log: console.log });
|
|
302
318
|
}
|
|
319
|
+
if (command === "mint") {
|
|
320
|
+
const count = values.get("count");
|
|
321
|
+
if (count !== undefined && !(/^\d{1,2}$/.test(count) && Number(count) >= 1 && Number(count) <= MAX_MINT)) {
|
|
322
|
+
throw new UsageError(`--count is how many Proofs to mint: 1 to ${MAX_MINT}`);
|
|
323
|
+
}
|
|
324
|
+
return mint(network, createProofsChain(network.proofs, signer().account), { count: count === undefined ? undefined : Number(count), dry: switches.has("dry"), log: console.log });
|
|
325
|
+
}
|
|
303
326
|
const worker = signer();
|
|
304
327
|
if (command === "run")
|
|
305
|
-
return run(network, worker, values, switches.has("once"));
|
|
328
|
+
return run(network, worker, values, switches.has("once"), !switches.has("no-mint"));
|
|
306
329
|
const chain = createChain(network, worker.account);
|
|
307
330
|
switch (command) {
|
|
308
331
|
case "join":
|
package/dist/mint.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { createPublicClient, createWalletClient, formatEther, http, parseAbi, parseEventLogs, zeroAddress, } from "viem";
|
|
2
|
+
import { reason, RECEIPT_MS } from "./chain.js";
|
|
3
|
+
import { proofsChainOf } from "./network.js";
|
|
4
|
+
import { when } from "./standing.js";
|
|
5
|
+
export const collectionAbi = parseAbi([
|
|
6
|
+
"function PRICE_QUOTE() view returns (uint256)",
|
|
7
|
+
"function mint(uint256 quantity, uint256 minRoutedTotal) payable",
|
|
8
|
+
"event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)",
|
|
9
|
+
"error SupplyExceeded()",
|
|
10
|
+
"error WalletCapExceeded()",
|
|
11
|
+
"error Ended()",
|
|
12
|
+
"error NotStarted()",
|
|
13
|
+
]);
|
|
14
|
+
export const wethAbi = parseAbi([
|
|
15
|
+
"function balanceOf(address) view returns (uint256)",
|
|
16
|
+
"function allowance(address owner, address spender) view returns (uint256)",
|
|
17
|
+
"function approve(address spender, uint256 amount) returns (bool)",
|
|
18
|
+
"function faucet() returns (uint256)",
|
|
19
|
+
"function nextFaucetAt(address) view returns (uint256)",
|
|
20
|
+
"error FaucetCoolingDown(uint256 nextAt)",
|
|
21
|
+
]);
|
|
22
|
+
/** The most pieces one mint run pays for. */
|
|
23
|
+
export const MAX_MINT = 10;
|
|
24
|
+
/** How often a delivered task's status is read until it settles. */
|
|
25
|
+
const STATUS_SEC = 300;
|
|
26
|
+
/** Grading, a dispute and its ruling take up to 6 hours each on mainnet, so a task unsettled after a day is left. */
|
|
27
|
+
const WATCH_SEC = 86_400;
|
|
28
|
+
/** A day after payment the task joins the public pool, so the worker's own attempts stop an hour short of it. */
|
|
29
|
+
const OWN_SEC = 23 * 3_600;
|
|
30
|
+
/** The least time between two mint attempts for one task. */
|
|
31
|
+
const RETRY_SEC = 3_600;
|
|
32
|
+
/** The service lists a payment a round or two after it lands; a task still unlisted this long after has its piece. */
|
|
33
|
+
const LISTING_SEC = 900;
|
|
34
|
+
/** The piece a mint's receipt shows the collection minting to `to`. */
|
|
35
|
+
export function mintedToken(logs, collection, to) {
|
|
36
|
+
// WETH's Transfer shares the event's signature but not its indexed token id, so only the collection's own logs decode.
|
|
37
|
+
return parseEventLogs({ abi: collectionAbi, eventName: "Transfer", args: { from: zeroAddress, to }, logs }).find((log) => log.address.toLowerCase() === collection.toLowerCase())?.args.tokenId;
|
|
38
|
+
}
|
|
39
|
+
export function createProofsChain(proofs, account) {
|
|
40
|
+
const chain = proofsChainOf(proofs);
|
|
41
|
+
const transport = http(proofs.rpc, { timeout: 20_000, retryCount: 3 });
|
|
42
|
+
const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
|
|
43
|
+
const wallet = createWalletClient({ account, chain, transport });
|
|
44
|
+
const weth = { address: proofs.weth, abi: wethAbi };
|
|
45
|
+
const collection = { address: proofs.collection, abi: collectionAbi };
|
|
46
|
+
async function send(request) {
|
|
47
|
+
const hash = await wallet.writeContract(request);
|
|
48
|
+
const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
|
|
49
|
+
if (receipt.status !== "success")
|
|
50
|
+
throw new Error(`Transaction ${hash} reverted`);
|
|
51
|
+
return { hash, receipt };
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
account: account.address,
|
|
55
|
+
price: () => client.readContract({ ...collection, functionName: "PRICE_QUOTE" }),
|
|
56
|
+
weth: () => client.readContract({ ...weth, functionName: "balanceOf", args: [account.address] }),
|
|
57
|
+
ethBalance: () => client.getBalance({ address: account.address }),
|
|
58
|
+
faucetOpensAt: () => client.readContract({ ...weth, functionName: "nextFaucetAt", args: [account.address] }),
|
|
59
|
+
async claimWeth() {
|
|
60
|
+
await send({ ...weth, functionName: "faucet" });
|
|
61
|
+
},
|
|
62
|
+
async approve(amount) {
|
|
63
|
+
const current = await client.readContract({ ...weth, functionName: "allowance", args: [account.address, proofs.collection] });
|
|
64
|
+
if (current >= amount)
|
|
65
|
+
return;
|
|
66
|
+
await send({ ...weth, functionName: "approve", args: [proofs.collection, amount] });
|
|
67
|
+
},
|
|
68
|
+
async mint() {
|
|
69
|
+
const { hash, receipt } = await send({ ...collection, functionName: "mint", args: [1n, 0n] });
|
|
70
|
+
const tokenId = mintedToken(receipt.logs, proofs.collection, account.address);
|
|
71
|
+
if (tokenId === undefined)
|
|
72
|
+
throw new Error(`The mint ${hash} minted no piece to ${account.address}`);
|
|
73
|
+
return { tokenId, tx: hash };
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The worker's paid tasks the service has bound no piece to, in the order it
|
|
79
|
+
* binds the worker's own mints to them: oldest payment first.
|
|
80
|
+
*/
|
|
81
|
+
export async function pendingTasks(network, worker, fetcher = fetch) {
|
|
82
|
+
const host = new URL(network.service).host;
|
|
83
|
+
const response = await fetcher(`${network.service}/proofs/${network.chainId}/pending/${worker}`, { signal: AbortSignal.timeout(15_000) }).catch(() => {
|
|
84
|
+
throw new Error(`Could not reach the BrainChain service at ${host}`);
|
|
85
|
+
});
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
await response.body?.cancel();
|
|
88
|
+
if (response.status === 404)
|
|
89
|
+
throw new Error(`The BrainChain service at ${host} does not list the tasks waiting for a Proof yet`);
|
|
90
|
+
throw new Error(`The BrainChain service at ${host} answered ${response.status} when asked for the tasks waiting for a Proof`);
|
|
91
|
+
}
|
|
92
|
+
const tasks = (await response.json().catch(() => undefined))?.tasks;
|
|
93
|
+
if (!Array.isArray(tasks) || !tasks.every((id) => typeof id === "string" && /^\d{1,78}$/.test(id))) {
|
|
94
|
+
throw new Error(`The BrainChain service at ${host} listed the tasks waiting for a Proof in a form the worker cannot read`);
|
|
95
|
+
}
|
|
96
|
+
return [...new Set(tasks)].map((id) => BigInt(id));
|
|
97
|
+
}
|
|
98
|
+
const proofCount = (n) => `${n} Proof${n === 1 ? "" : "s"}`;
|
|
99
|
+
/** "task 12", "tasks 12 and 15", "tasks 12, 15 and 18". */
|
|
100
|
+
function taskList(tasks) {
|
|
101
|
+
const ids = tasks.map(String);
|
|
102
|
+
if (ids.length === 1)
|
|
103
|
+
return `task ${ids[0]}`;
|
|
104
|
+
return `tasks ${ids.slice(0, -1).join(", ")} and ${ids.at(-1)}`;
|
|
105
|
+
}
|
|
106
|
+
const wall = () => Math.floor(Date.now() / 1000);
|
|
107
|
+
/**
|
|
108
|
+
* What the wallet lacks to pay for `count` pieces, if anything. Short of test
|
|
109
|
+
* WETH while the faucet is open, it claims from the faucet first, calling
|
|
110
|
+
* `claiming` as it starts, unless `dry`, which only sets `claim`.
|
|
111
|
+
*/
|
|
112
|
+
async function afford(proofs, chain, count, options) {
|
|
113
|
+
const [price, eth] = await Promise.all([chain.price(), chain.ethBalance()]);
|
|
114
|
+
const cost = price * BigInt(count);
|
|
115
|
+
let weth = await chain.weth();
|
|
116
|
+
let opensAt = 0n;
|
|
117
|
+
if (weth < cost && proofs.faucet) {
|
|
118
|
+
opensAt = await chain.faucetOpensAt();
|
|
119
|
+
// The claim pays gas too.
|
|
120
|
+
if (opensAt <= BigInt(options.now()) && eth > 0n) {
|
|
121
|
+
if (options.dry)
|
|
122
|
+
return { price, claim: true };
|
|
123
|
+
options.claiming();
|
|
124
|
+
await chain.claimWeth();
|
|
125
|
+
[weth, opensAt] = await Promise.all([chain.weth(), chain.faucetOpensAt()]);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const needWeth = weth < cost;
|
|
129
|
+
const needEth = eth === 0n;
|
|
130
|
+
if (!needWeth && !needEth)
|
|
131
|
+
return { price };
|
|
132
|
+
const fixes = [];
|
|
133
|
+
if (needWeth && !proofs.faucet)
|
|
134
|
+
fixes.push(`Send it ${formatEther(cost - weth)} WETH (${proofs.weth}) on ${proofs.name}.`);
|
|
135
|
+
if (needEth)
|
|
136
|
+
fixes.push(`${proofs.gas}.`);
|
|
137
|
+
if (needWeth && proofs.faucet) {
|
|
138
|
+
fixes.push(opensAt <= BigInt(options.now())
|
|
139
|
+
? "The worker claims test WETH from the faucet once it has ETH."
|
|
140
|
+
: `The test WETH faucet allows one claim a day, and this wallet can claim again at ${when(opensAt)}.`);
|
|
141
|
+
}
|
|
142
|
+
const holds = `${chain.account} holds ${formatEther(weth)} WETH${needEth ? " and no ETH" : ""} on ${proofs.name}`;
|
|
143
|
+
return { price, missing: `${holds}, and ${proofCount(count)} cost${count === 1 ? "s" : ""} ${formatEther(cost)} WETH plus gas. ${fixes.join(" ")}` };
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Mints a piece for each of the worker's paid tasks the service lists without
|
|
147
|
+
* one, up to `count` and at most MAX_MINT, from the worker's key. The service
|
|
148
|
+
* binds each piece the worker mints to its oldest paid task waiting for one.
|
|
149
|
+
*/
|
|
150
|
+
export async function mint(network, chain, options) {
|
|
151
|
+
const { log } = options;
|
|
152
|
+
const { proofs } = network;
|
|
153
|
+
const listed = await pendingTasks(network, chain.account, options.fetcher);
|
|
154
|
+
if (listed.length === 0)
|
|
155
|
+
return log(`The BrainChain service lists no paid task of ${chain.account} waiting for a Proof, so there is nothing to mint.`);
|
|
156
|
+
const batch = listed.slice(0, Math.min(options.count ?? MAX_MINT, MAX_MINT));
|
|
157
|
+
const left = listed.length - batch.length;
|
|
158
|
+
const rest = left > 0 ? ` ${left} more paid task${left === 1 ? " waits" : "s wait"} for a Proof: run mint again.` : "";
|
|
159
|
+
const funds = await afford(proofs, chain, batch.length, { dry: options.dry, claiming: () => log("Claiming test WETH from the faucet..."), now: options.now ?? wall });
|
|
160
|
+
const each = `${formatEther(funds.price)} WETH each plus gas`;
|
|
161
|
+
if (options.dry) {
|
|
162
|
+
log(`Dry run, nothing sent: mint would mint ${proofCount(batch.length)} on ${proofs.name} for ${taskList(batch)}, at ${each}.${rest}`);
|
|
163
|
+
if (funds.claim)
|
|
164
|
+
log("The wallet is short of WETH, so mint would claim test WETH from the faucet first.");
|
|
165
|
+
if (funds.missing)
|
|
166
|
+
log(funds.missing);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (funds.missing)
|
|
170
|
+
throw new Error(`${funds.missing} Then run mint again.`);
|
|
171
|
+
log(`Minting ${proofCount(batch.length)} on ${proofs.name} at ${each}...`);
|
|
172
|
+
for (const task of batch) {
|
|
173
|
+
await chain.approve(funds.price);
|
|
174
|
+
const { tokenId, tx } = await chain.mint();
|
|
175
|
+
log(`Minted Proof ${tokenId} for task ${task} (${tx}).`);
|
|
176
|
+
}
|
|
177
|
+
if (rest)
|
|
178
|
+
log(rest.trim());
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* What `run` does for Proofs: it watches each task the worker delivers until
|
|
182
|
+
* it settles and mints a piece for each paid one, at most once an hour per
|
|
183
|
+
* task, and only while the service lists the task as waiting for a piece. The
|
|
184
|
+
* watched tasks live in the state, so a restart neither forgets nor repeats a
|
|
185
|
+
* mint. With `enabled` false, as under --no-mint, it does nothing.
|
|
186
|
+
*/
|
|
187
|
+
export function createProofMinter(options) {
|
|
188
|
+
const { network, chain, store, log } = options;
|
|
189
|
+
const clock = options.now ?? wall;
|
|
190
|
+
const watched = () => (store.state.proofs ??= {});
|
|
191
|
+
const drop = (key, why) => {
|
|
192
|
+
delete watched()[key];
|
|
193
|
+
store.save();
|
|
194
|
+
if (why)
|
|
195
|
+
log(`task ${key}: ${why}`);
|
|
196
|
+
};
|
|
197
|
+
const later = (entry, seconds) => {
|
|
198
|
+
entry.nextAt = clock() + seconds;
|
|
199
|
+
store.save();
|
|
200
|
+
};
|
|
201
|
+
/** Reads whether a delivered task settled; true once it is paid. */
|
|
202
|
+
async function settle(key, entry) {
|
|
203
|
+
let outcome;
|
|
204
|
+
try {
|
|
205
|
+
outcome = await options.settled(BigInt(key));
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
later(entry, STATUS_SEC);
|
|
209
|
+
log(`task ${key}: could not read whether it was paid (${reason(error)})`);
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
if (outcome === "refunded") {
|
|
213
|
+
drop(key, "refunded to its poster, so it gets no proof");
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
if (outcome === undefined) {
|
|
217
|
+
if (clock() - entry.deliveredAt >= WATCH_SEC)
|
|
218
|
+
drop(key, "not settled a day after delivery, so the worker mints no proof for it");
|
|
219
|
+
else
|
|
220
|
+
later(entry, STATUS_SEC);
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
entry.paidAt = clock();
|
|
224
|
+
store.save();
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
async function mintFor(key, entry, listed) {
|
|
228
|
+
const paidAt = entry.paidAt;
|
|
229
|
+
if (clock() - paidAt >= OWN_SEC)
|
|
230
|
+
return drop(key, "no proof minted within a day of payment, so the task joins the public pool");
|
|
231
|
+
if (Array.isArray(listed) && !listed.includes(BigInt(key))) {
|
|
232
|
+
if (clock() - paidAt < LISTING_SEC)
|
|
233
|
+
return later(entry, 60);
|
|
234
|
+
return drop(key, "the BrainChain service lists no proof waiting for it, so the worker mints none");
|
|
235
|
+
}
|
|
236
|
+
// Counted before anything is sent, so a restart during the mint waits out the hour too.
|
|
237
|
+
later(entry, RETRY_SEC);
|
|
238
|
+
if (listed instanceof Error)
|
|
239
|
+
return log(`task ${key}: no proof minted yet (${reason(listed)}); trying again in an hour`);
|
|
240
|
+
try {
|
|
241
|
+
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
|
+
return log(`task ${key}: no proof minted yet. ${funds.missing} Trying again in an hour.`);
|
|
244
|
+
await chain.approve(funds.price);
|
|
245
|
+
const { tokenId, tx } = await chain.mint();
|
|
246
|
+
drop(key);
|
|
247
|
+
log(`minted proof ${tokenId} for task ${key} (${tx})`);
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
log(`task ${key}: minting its proof failed (${reason(error)}); trying again in an hour`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
/** Watches a task the worker just delivered. */
|
|
255
|
+
track(task) {
|
|
256
|
+
if (!options.enabled)
|
|
257
|
+
return;
|
|
258
|
+
const now = clock();
|
|
259
|
+
watched()[task.toString()] = { deliveredAt: now, nextAt: now + STATUS_SEC };
|
|
260
|
+
store.save();
|
|
261
|
+
},
|
|
262
|
+
/** Reads every watched task that is due, and mints for each paid one. */
|
|
263
|
+
async round() {
|
|
264
|
+
if (!options.enabled || store.state.proofs === undefined)
|
|
265
|
+
return;
|
|
266
|
+
const due = Object.entries(watched()).filter(([, entry]) => clock() >= entry.nextAt);
|
|
267
|
+
const paid = [];
|
|
268
|
+
for (const [key, entry] of due)
|
|
269
|
+
if (entry.paidAt !== undefined || (await settle(key, entry)))
|
|
270
|
+
paid.push([key, entry]);
|
|
271
|
+
if (paid.length === 0)
|
|
272
|
+
return;
|
|
273
|
+
const listed = await pendingTasks(network, chain.account, options.fetcher).catch((error) => (error instanceof Error ? error : new Error(String(error))));
|
|
274
|
+
for (const [key, entry] of paid)
|
|
275
|
+
await mintFor(key, entry, listed);
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
package/dist/network.js
CHANGED
|
@@ -11,7 +11,15 @@ export const TESTNET = {
|
|
|
11
11
|
stakeSymbol: "tBRAIN",
|
|
12
12
|
bountySymbol: "tUSDG",
|
|
13
13
|
gas: "Get Robinhood Chain testnet ETH from https://faucet.testnet.chain.robinhood.com and bridge some at https://usebrainchain.com/testnet",
|
|
14
|
-
proofs: {
|
|
14
|
+
proofs: {
|
|
15
|
+
chainId: 46630,
|
|
16
|
+
name: "Robinhood Chain testnet",
|
|
17
|
+
rpc: "https://rpc.testnet.chain.robinhood.com",
|
|
18
|
+
collection: "0x324B52EE2182641ec29652076ab541bc9F9907d2",
|
|
19
|
+
weth: "0x1F852eF3Afa13156b807204d838BbB46B4b452eb",
|
|
20
|
+
faucet: true,
|
|
21
|
+
gas: "Get Robinhood Chain testnet ETH for gas at https://faucet.testnet.chain.robinhood.com",
|
|
22
|
+
},
|
|
15
23
|
contracts: {
|
|
16
24
|
taskBoard: "0xC74564F100e70F95346A6e399ebf1Ce14309C8B6",
|
|
17
25
|
staking: "0xbdC08799D865DA79FE44a469de1FeaE84bb45059",
|
|
@@ -42,7 +50,15 @@ export const MAINNET = {
|
|
|
42
50
|
buyStake: "Buy BRAIN on ripples.run (https://ripples.run/t/0xa06e3D898Bce92F67617E0Bfb640F153A5b65AB7) and bridge it to BrainChain through the " +
|
|
43
51
|
"Robinhood Chain gateway router 0x03f06a370A59AfB23de029F4EF5EA2F9EB034F96 (docs: https://docs.usebrainchain.com/bridging); " +
|
|
44
52
|
`the stake token on BrainChain is ${BRAIN}.`,
|
|
45
|
-
proofs: {
|
|
53
|
+
proofs: {
|
|
54
|
+
chainId: 4663,
|
|
55
|
+
name: "Robinhood Chain",
|
|
56
|
+
rpc: "https://rpc.mainnet.chain.robinhood.com",
|
|
57
|
+
collection: "0xE6E6B7DBF1a66dFB25c6F6A87a916B3186113169",
|
|
58
|
+
weth: "0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73",
|
|
59
|
+
gas: "Send it a little ETH there for gas",
|
|
60
|
+
income: "0xeb1b4168D4b87D3d9F0D64E13B7071251BBB63b7",
|
|
61
|
+
},
|
|
46
62
|
contracts: {
|
|
47
63
|
taskBoard: MAINNET_TASK_BOARD,
|
|
48
64
|
staking: MAINNET_WORKER_STAKING,
|
|
@@ -90,3 +106,13 @@ export function chainOf(network) {
|
|
|
90
106
|
rpcUrls: { default: { http: [network.rpc] } },
|
|
91
107
|
});
|
|
92
108
|
}
|
|
109
|
+
const MULTICALL3 = "0xcA11bde05977b3631167028862bE2a173976CA11";
|
|
110
|
+
export function proofsChainOf(proofs) {
|
|
111
|
+
return defineChain({
|
|
112
|
+
id: proofs.chainId,
|
|
113
|
+
name: proofs.name,
|
|
114
|
+
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
|
115
|
+
rpcUrls: { default: { http: [proofs.rpc] } },
|
|
116
|
+
contracts: { multicall3: { address: MULTICALL3 } },
|
|
117
|
+
});
|
|
118
|
+
}
|
package/dist/state.js
CHANGED
|
@@ -19,15 +19,22 @@ function isTask(value) {
|
|
|
19
19
|
return false;
|
|
20
20
|
return value.stored === undefined || (isObject(value.stored) && typeof value.stored.uri === "string" && isHash(value.stored.hash));
|
|
21
21
|
}
|
|
22
|
+
function isProofs(value) {
|
|
23
|
+
if (!isObject(value) || !isIds(Object.keys(value)))
|
|
24
|
+
return false;
|
|
25
|
+
return Object.values(value).every((p) => isObject(p) && isCount(p.deliveredAt) && isCount(p.nextAt) && (p.paidAt === undefined || isCount(p.paidAt)));
|
|
26
|
+
}
|
|
22
27
|
/** Whether a parsed file is a state this worker can go on from; any other shape fails every round that reads it. */
|
|
23
28
|
function isState(value) {
|
|
24
29
|
if (!isObject(value) || typeof value.board !== "string" || !isObject(value.tasks) || !isIds(value.done))
|
|
25
30
|
return false;
|
|
26
31
|
if (!Object.values(value.tasks).every(isTask))
|
|
27
32
|
return false;
|
|
28
|
-
const { hired, command } = value;
|
|
33
|
+
const { hired, command, proofs } = value;
|
|
29
34
|
if (hired !== undefined && !(isObject(hired) && isCount(hired.block) && isIds(hired.tasks)))
|
|
30
35
|
return false;
|
|
36
|
+
if (proofs !== undefined && !isProofs(proofs))
|
|
37
|
+
return false;
|
|
31
38
|
// Group 0 is this process's own and -1 is every process, so only a real command's group is signalled.
|
|
32
39
|
return command === undefined || (isObject(command) && isCount(command.pgid) && command.pgid > 1 && isCount(command.startedAt));
|
|
33
40
|
}
|
package/dist/work.js
CHANGED
|
@@ -315,6 +315,7 @@ export function createWork(options) {
|
|
|
315
315
|
const delivered = (key, text, tx) => {
|
|
316
316
|
result.delivered += 1;
|
|
317
317
|
log(`task ${key}: delivered (${toBytes(text).byteLength} bytes, ${tx}). Jev grades it next.`);
|
|
318
|
+
options.delivered?.(BigInt(key));
|
|
318
319
|
};
|
|
319
320
|
const current = new Set(hired.map((t) => t.id.toString()));
|
|
320
321
|
for (const [key, attempt] of Object.entries(state.tasks)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brainchain-worker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Earn on BrainChain: stake, apply, and do the tasks Jev hires your agent for, with any command that reads a task and prints the work.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "BrainChain <hello@usebrainchain.com>",
|