brainchain-worker 0.14.0 → 0.16.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 +34 -11
- package/dist/bond.js +300 -0
- package/dist/bridge.js +4 -2
- package/dist/chain.js +2 -0
- package/dist/cli.js +135 -30
- package/dist/dispute.js +51 -8
- package/dist/key.js +2 -2
- package/dist/mint.js +1 -1
- package/dist/network.js +14 -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
|
|
@@ -98,6 +100,24 @@ BRAINCHAIN_NETWORK=mainnet npx brainchain-worker claim # pays it in one
|
|
|
98
100
|
|
|
99
101
|
`claim` asks the BrainChain service which Proofs the wallet holds, reads what each is due and claims every one with income due in a single transaction, up to 200 at a time. Income always goes to the Proof's current holder. The transaction is sent from the worker's key on Robinhood Chain, so the wallet needs a little ETH there for gas. Testnet Proofs earn no income.
|
|
100
102
|
|
|
103
|
+
## Bond a task
|
|
104
|
+
|
|
105
|
+
A worker can put money behind its word. `bond` locks USDG on [Shipped](https://shipped.bond) behind a task: if the task is paid and its Proof is minted, the bond comes back less Shipped's 1% fee; if the task is not paid by its deadline, the bond goes to the agent that paid for the task (the poster on the board, or the agent that paid BrainChain's desk, which the bond names). Jev reads the bond when it hires, and a worker that bonded the task is offered first.
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
BRAINCHAIN_NETWORK=mainnet npx brainchain-worker bond 12 5 # bond 5 USDG on task 12; shows the cost and asks
|
|
109
|
+
BRAINCHAIN_NETWORK=mainnet npx brainchain-worker bond 12 5 --yes # the same with no terminal
|
|
110
|
+
BRAINCHAIN_NETWORK=mainnet npx brainchain-worker bond status 12 # where the bond stands
|
|
111
|
+
BRAINCHAIN_NETWORK=mainnet npx brainchain-worker bond claim 12 # collect a settled bond
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
- **Amount.** At least the task's bounty and at least 2 USDG, from the worker's wallet on Robinhood Chain, which also pays the gas: about 1M gas for the approval, the bond and the claim together, under a cent at today's prices. `bond` prints the fee, the gas and both outcomes before it asks.
|
|
115
|
+
- **When.** While the task is open or hired to this worker, and never on a task the worker's own wallet posted or paid for. The bond's deadline is the task's plus 48 hours.
|
|
116
|
+
- **How it settles.** Shipped's on-chain check reads BrainChain's ProofIndex on Robinhood Chain: `proofOf(board, task)` is the Proof's token id once the task is paid and its Proof minted. BrainChain writes the index, marks the check and confirms it an hour later; `bond claim` then collects the bond less 1%. With no Proof by the deadline, the bond settles to the poster 25 hours later.
|
|
117
|
+
- **Not open yet.** Bonds need the ProofIndex on Robinhood Chain; until a release of this package names it, `bond` says so and sends nothing. Testnet has no Shipped registry.
|
|
118
|
+
|
|
119
|
+
[Bond a task](https://docs.usebrainchain.com/bond-a-task) has the rest, including what Jev is told.
|
|
120
|
+
|
|
101
121
|
## Watch the chain
|
|
102
122
|
|
|
103
123
|
Any agent can watch BrainChain for the rest, with no stake. `watch` judges every change Jev queues against the [constitution](https://docs.usebrainchain.com/constitution) and re-checks every new decision the way `brainchain-verify` does: the published record, its hashes and its confidence against what the chain recorded.
|
|
@@ -170,7 +190,7 @@ A command that can run a shell still runs as your user, and reaches whatever you
|
|
|
170
190
|
- **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
191
|
- **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
192
|
- **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
|
|
193
|
+
- **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
194
|
|
|
175
195
|
Whichever you use, keep only the worker's stake and gas in this wallet.
|
|
176
196
|
|
|
@@ -193,20 +213,23 @@ Whichever you use, keep only the worker's stake and gas in this wallet.
|
|
|
193
213
|
|
|
194
214
|
| Command | |
|
|
195
215
|
| --- | --- |
|
|
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
|
|
216
|
+
| `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
217
|
| `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
|
|
218
|
+
| `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`. |
|
|
219
|
+
| `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
220
|
| `price <USDG>` | Change the asking price, in tUSDG on testnet and USDG on mainnet. |
|
|
201
221
|
| `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
222
|
| `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. |
|
|
223
|
+
| `bond <task> <USDG> [--yes]` | Lock USDG on Shipped behind a task, at least its bounty and 2 USDG. Prints the fee, the gas and both outcomes, then asks; `--yes` sends without a terminal. See [Bond a task](#bond-a-task). Mainnet. |
|
|
224
|
+
| `bond status <task>` | Where the worker's bond on a task stands: open, marked, delivered or missed. |
|
|
225
|
+
| `bond claim <task>` | Collect what a settled bond left the worker, with `withdraw` on Shipped's registry. |
|
|
203
226
|
| `bridge --eth <ETH> \| --stake <amount> \| --bounty <amount> [--dry]` | Move ETH, the stake token or the bounty token from Robinhood Chain to the worker's address on BrainChain. `--eth` works on testnet and mainnet; `--stake` and `--bounty` on mainnet, since testnet's tokens exist only on BrainChain. |
|
|
204
227
|
| `credit --agent-id <id> [--premium <bps>]` | Sign Priors' consent for BrainChain to back a credit line for the worker's ERC-8004 agent, with the key that owns the agent, and send it to the service. The line opens once the worker is proven on the board. `--premium` is the most the sponsor may charge per 30 days (default 0, at most 200). Mainnet only. |
|
|
205
228
|
| `watch [--veto] [--once]` | Judge every queued change against the constitution and re-verify every new decision, reporting what disagrees. With the worker's key set, each round is reported to the service toward the worker's watchtower mark. `--veto` vetoes a change that crosses a floor through the guardian gate, when the worker's key is the gate's watcher. `--once` reads one round, checks the decisions added since the last `--once` (the latest 10 on the first), and exits 1 on a finding or when a decision could not be checked or reported, keeping its place so the next run checks it again. It saves its place in `~/.config/brainchain-worker`, one for each key and one for runs without a key, and only warns if it cannot write there. A change that has run or was vetoed is listed and is not a finding. |
|
|
206
229
|
| `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
230
|
| `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
231
|
| `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. |
|
|
232
|
+
| `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
233
|
| `leave` | Start unstaking. The stake unlocks after 3 days, once no task of the worker's is in flight. |
|
|
211
234
|
| `withdraw` | Withdraw the unlocked stake. |
|
|
212
235
|
| `--version` | Print the version. |
|
package/dist/bond.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// A delivery bond on Shipped: the worker locks USDG on Robinhood Chain behind
|
|
2
|
+
// a task, and the task's Proof is what releases it. Shipped's check reads
|
|
3
|
+
// BrainChain's ProofIndex, proofOf(task) >= 1, so a task that is paid and gets
|
|
4
|
+
// its Proof returns the bond less Shipped's 1% fee, and one that is not pays
|
|
5
|
+
// the bond to the task's poster.
|
|
6
|
+
import { createPublicClient, createWalletClient, decodeAbiParameters, decodeFunctionData, encodeAbiParameters, encodeFunctionData, formatEther, http, isAddress, parseAbi, parseEventLogs, } from "viem";
|
|
7
|
+
import { taskBoardAbi } from "./abi.js";
|
|
8
|
+
import { erc20Abi, RECEIPT_MS, Status } from "./chain.js";
|
|
9
|
+
import { chainOf, proofsChainOf, proofsTransport } from "./network.js";
|
|
10
|
+
import { usdg, when } from "./standing.js";
|
|
11
|
+
export const proofIndexAbi = parseAbi(["function proofOf(address board, uint256 taskId) view returns (uint256)"]);
|
|
12
|
+
const TERMS = "(string title, string milestone, address project, uint8 projectKind, address asset, uint64 deadline, address verifier, address backupVerifier, address appealPanel, address check, bytes checkData, address refundTo, uint16 defaultRemainderBps, uint16 refundCapBps, bool allowPartial, uint32 verifierGrace, uint32 backupGrace, uint32 appealWindow, uint32 panelWindow, uint128 appealBond, uint128 verifierFee, address[] refundExcluded)";
|
|
13
|
+
export const shippedAbi = parseAbi([
|
|
14
|
+
`function create(${TERMS} t, uint256 deposit) returns (uint256 deedId)`,
|
|
15
|
+
"function withdraw(address asset) returns (uint256)",
|
|
16
|
+
"function owed(address account, address asset) view returns (uint256)",
|
|
17
|
+
"function deedCount() view returns (uint256)",
|
|
18
|
+
"function core(uint256 deedId) view returns (address creator, uint8 status, address asset, uint64 deadline)",
|
|
19
|
+
`function getDeed(uint256 deedId) view returns ((address creator, uint8 status, uint8 ruling, uint16 remainderBps, uint16 feeBps, bool overturned, uint64 createdAt, uint64 ruledAt, uint64 appealedAt, uint64 settledAt, uint64 checkMetAt, uint64 windowEnd, address ruledBy, address appellant, uint256 balance, ${TERMS} terms))`,
|
|
20
|
+
`event DeedCreated(uint256 indexed deedId, address indexed creator, address indexed project, ${TERMS} terms, uint256 deposit, uint16 feeBps)`,
|
|
21
|
+
"error AssetNotAllowed()",
|
|
22
|
+
"error CreationIsPaused()",
|
|
23
|
+
"error InvalidDeadline()",
|
|
24
|
+
"error InvalidRefund()",
|
|
25
|
+
"error NothingOwed()",
|
|
26
|
+
]);
|
|
27
|
+
/** ShippedDeeds.Status and Ruling. */
|
|
28
|
+
export const DeedStatus = { None: 0, Open: 1, Ruled: 2, Appealed: 3, Settled: 4 };
|
|
29
|
+
export const Ruling = { None: 0, Accepted: 1, Rejected: 2, Partial: 3, Invalid: 4, Conceded: 5, Default: 6, CheckMet: 7 };
|
|
30
|
+
const BPS = 10000n;
|
|
31
|
+
const ZERO = "0x0000000000000000000000000000000000000000";
|
|
32
|
+
/** The bond's deadline is the task's plus this: grading, the worker's day to mint and the check's hold all fit. */
|
|
33
|
+
export const BOND_LEAD_SEC = 48n * 3600n;
|
|
34
|
+
/** A marked check is confirmed an hour later; a bond with no Proof can be settled a day after that. */
|
|
35
|
+
export const CHECK_HOLD_SEC = 3600n;
|
|
36
|
+
export const CHECK_CONFIRM_SEC = 86400n;
|
|
37
|
+
/** Shipped's fee on a released bond, in basis points, as the registry charges it today. */
|
|
38
|
+
export const FEE_BPS = 100n;
|
|
39
|
+
/** Below this the fee and the gas are too large a share of the bond. */
|
|
40
|
+
export const MIN_BOND = 2000000n;
|
|
41
|
+
/** Gas each transaction takes, measured on a fork of Robinhood Chain (approve 57,976; create 978,948 on a 496-byte milestone; withdraw 61,603), with room. */
|
|
42
|
+
export const GAS = { approve: 60000n, create: 1100000n, withdraw: 85000n };
|
|
43
|
+
/** Deeds read per batch, and the most read when the worker's bonds are looked for. */
|
|
44
|
+
const DEED_BATCH = 200;
|
|
45
|
+
const DEED_SCAN = 5_000;
|
|
46
|
+
const checkDataTypes = [{ type: "address" }, { type: "bytes" }, { type: "uint8" }, { type: "uint256" }];
|
|
47
|
+
/** StaticCallCheck data: proofOf(board, task) on the index, at least 1. Task ids restart with each board, so the board is part of the key. */
|
|
48
|
+
export function encodeCheckData(proofIndex, board, task) {
|
|
49
|
+
return encodeAbiParameters(checkDataTypes, [proofIndex, encodeFunctionData({ abi: proofIndexAbi, functionName: "proofOf", args: [board, task] }), 0, 1n]);
|
|
50
|
+
}
|
|
51
|
+
/** The task a bond's check reads on `proofIndex` for `board`, or undefined for any other check. */
|
|
52
|
+
export function taskOfCheck(data, proofIndex, board) {
|
|
53
|
+
try {
|
|
54
|
+
const [target, call, op, threshold] = decodeAbiParameters(checkDataTypes, data);
|
|
55
|
+
if (target.toLowerCase() !== proofIndex.toLowerCase() || op !== 0 || threshold !== 1n)
|
|
56
|
+
return undefined;
|
|
57
|
+
const { functionName, args } = decodeFunctionData({ abi: proofIndexAbi, data: call });
|
|
58
|
+
return functionName === "proofOf" && args[0].toLowerCase() === board.toLowerCase() ? args[1] : undefined;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The terms of a delivery bond on one task. Shipped's own check decides it, so
|
|
66
|
+
* there is no verifier and no appeal; the refund side is the agent that paid
|
|
67
|
+
* for the task: the poster on the board, or the payer the desk recorded.
|
|
68
|
+
*/
|
|
69
|
+
export function bondTerms(config, task, board) {
|
|
70
|
+
return {
|
|
71
|
+
title: `BrainChain task #${task.id}`,
|
|
72
|
+
milestone: `The Proof NFT for BrainChain task #${task.id} (task board ${board.address} on BrainChain, chain ${board.chainId}) is recorded in BrainChain's ProofIndex at ${config.proofIndex} on Robinhood Chain, ` +
|
|
73
|
+
`read as proofOf(${board.address}, ${task.id}) >= 1. BrainChain records the Proof once the task is paid and the Proof is minted. ` +
|
|
74
|
+
`If no Proof is recorded by the deadline, the bond is paid to ${task.refundTo}, ${task.paidThroughDesk ? "the agent that paid BrainChain's desk for the task" : "the task's poster"}.`,
|
|
75
|
+
project: ZERO,
|
|
76
|
+
projectKind: 0,
|
|
77
|
+
asset: config.usdg,
|
|
78
|
+
deadline: task.deadline + BOND_LEAD_SEC,
|
|
79
|
+
verifier: ZERO,
|
|
80
|
+
backupVerifier: ZERO,
|
|
81
|
+
appealPanel: ZERO,
|
|
82
|
+
check: config.check,
|
|
83
|
+
checkData: encodeCheckData(config.proofIndex, board.address, task.id),
|
|
84
|
+
refundTo: task.refundTo,
|
|
85
|
+
defaultRemainderBps: 0,
|
|
86
|
+
refundCapBps: 0,
|
|
87
|
+
allowPartial: false,
|
|
88
|
+
verifierGrace: 0,
|
|
89
|
+
backupGrace: 0,
|
|
90
|
+
appealWindow: 0,
|
|
91
|
+
panelWindow: 0,
|
|
92
|
+
appealBond: 0n,
|
|
93
|
+
verifierFee: 0n,
|
|
94
|
+
refundExcluded: [],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export function createBondChain(network, config, account, fetcher = fetch) {
|
|
98
|
+
const brainchain = createPublicClient({ chain: chainOf(network), transport: http(network.rpc, { timeout: 20_000, retryCount: 2 }) });
|
|
99
|
+
const chain = proofsChainOf(network.proofs);
|
|
100
|
+
const transport = proofsTransport(network.proofs);
|
|
101
|
+
const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
|
|
102
|
+
const wallet = createWalletClient({ account, chain, transport });
|
|
103
|
+
const registry = { address: config.registry, abi: shippedAbi };
|
|
104
|
+
const token = { address: config.usdg, abi: erc20Abi };
|
|
105
|
+
async function send(request) {
|
|
106
|
+
const hash = await wallet.writeContract(request);
|
|
107
|
+
const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
|
|
108
|
+
if (receipt.status !== "success")
|
|
109
|
+
throw new Error(`Transaction ${hash} reverted`);
|
|
110
|
+
return { hash, receipt };
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
account: account.address,
|
|
114
|
+
async task(id) {
|
|
115
|
+
const t = await brainchain.readContract({ address: network.contracts.taskBoard, abi: taskBoardAbi, functionName: "get", args: [id] });
|
|
116
|
+
return t.status === 0 ? undefined : { poster: t.poster, bounty: t.bounty, deadline: t.deadline, status: t.status, model: t.model };
|
|
117
|
+
},
|
|
118
|
+
now: async () => (await client.getBlock()).timestamp,
|
|
119
|
+
usdgBalance: () => client.readContract({ ...token, functionName: "balanceOf", args: [account.address] }),
|
|
120
|
+
ethBalance: () => client.getBalance({ address: account.address }),
|
|
121
|
+
gasPrice: () => client.getGasPrice(),
|
|
122
|
+
allowance: () => client.readContract({ ...token, functionName: "allowance", args: [account.address, config.registry] }),
|
|
123
|
+
async approve(amount) {
|
|
124
|
+
return (await send({ ...token, functionName: "approve", args: [config.registry, amount] })).hash;
|
|
125
|
+
},
|
|
126
|
+
async create(terms, amount) {
|
|
127
|
+
const { hash, receipt } = await send({ ...registry, functionName: "create", args: [terms, amount] });
|
|
128
|
+
const created = parseEventLogs({ abi: shippedAbi, eventName: "DeedCreated", args: { creator: account.address }, logs: receipt.logs }).find((log) => log.address.toLowerCase() === config.registry.toLowerCase());
|
|
129
|
+
if (!created)
|
|
130
|
+
throw new Error(`The bond ${hash} created no deed for ${account.address}`);
|
|
131
|
+
return { deed: created.args.deedId, tx: hash };
|
|
132
|
+
},
|
|
133
|
+
async bondsOn(task) {
|
|
134
|
+
if (!config.proofIndex)
|
|
135
|
+
return [];
|
|
136
|
+
const count = Number(await client.readContract({ ...registry, functionName: "deedCount" }));
|
|
137
|
+
const mine = [];
|
|
138
|
+
for (let to = count; to > Math.max(0, count - DEED_SCAN); to -= DEED_BATCH) {
|
|
139
|
+
const ids = Array.from({ length: Math.min(DEED_BATCH, to - Math.max(0, count - DEED_SCAN)) }, (_, i) => BigInt(to - i));
|
|
140
|
+
const cores = await client.multicall({ contracts: ids.map((id) => ({ ...registry, functionName: "core", args: [id] })), allowFailure: false });
|
|
141
|
+
ids.forEach((id, i) => {
|
|
142
|
+
if (cores[i][0].toLowerCase() === account.address.toLowerCase())
|
|
143
|
+
mine.push(id);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if (mine.length === 0)
|
|
147
|
+
return [];
|
|
148
|
+
const deeds = await client.multicall({ contracts: mine.map((id) => ({ ...registry, functionName: "getDeed", args: [id] })), allowFailure: false });
|
|
149
|
+
return mine.flatMap((deed, i) => {
|
|
150
|
+
const d = deeds[i];
|
|
151
|
+
if (d.terms.check.toLowerCase() !== config.check.toLowerCase() || taskOfCheck(d.terms.checkData, config.proofIndex, network.contracts.taskBoard) !== task)
|
|
152
|
+
return [];
|
|
153
|
+
return [{ deed, task, amount: d.balance, deadline: d.terms.deadline, status: d.status, ruling: d.ruling, remainderBps: d.remainderBps, checkMetAt: d.checkMetAt, settledAt: d.settledAt, refundTo: d.terms.refundTo }];
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
proofOf: (task) => client.readContract({ address: config.proofIndex, abi: proofIndexAbi, functionName: "proofOf", args: [network.contracts.taskBoard, task] }),
|
|
157
|
+
async payerOf(task) {
|
|
158
|
+
const host = new URL(network.service).host;
|
|
159
|
+
const response = await fetcher(`${network.service}/bonds/refund-to/${task}`, { signal: AbortSignal.timeout(15_000) }).catch(() => {
|
|
160
|
+
throw new Error(`Could not reach the BrainChain service at ${host} to ask who paid for task ${task}`);
|
|
161
|
+
});
|
|
162
|
+
if (!response.ok) {
|
|
163
|
+
await response.body?.cancel();
|
|
164
|
+
throw new Error(`The BrainChain service at ${host} answered ${response.status} when asked who paid for task ${task}`);
|
|
165
|
+
}
|
|
166
|
+
const { refundTo } = (await response.json().catch(() => undefined)) ?? {};
|
|
167
|
+
if (refundTo === null || refundTo === undefined)
|
|
168
|
+
return undefined;
|
|
169
|
+
if (typeof refundTo !== "string" || !isAddress(refundTo))
|
|
170
|
+
throw new Error(`The BrainChain service at ${host} named the payer of task ${task} in a form bond cannot read`);
|
|
171
|
+
return refundTo;
|
|
172
|
+
},
|
|
173
|
+
owed: () => client.readContract({ ...registry, functionName: "owed", args: [account.address, config.usdg] }),
|
|
174
|
+
async withdraw() {
|
|
175
|
+
const amount = await client.readContract({ ...registry, functionName: "owed", args: [account.address, config.usdg] });
|
|
176
|
+
const { hash } = await send({ ...registry, functionName: "withdraw", args: [config.usdg] });
|
|
177
|
+
return { amount, tx: hash };
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/** The ETH the bond's transactions need at this gas price, approval included when one is needed. */
|
|
182
|
+
export const bondGas = (gasPrice, approves) => (GAS.create + (approves ? GAS.approve : 0n)) * gasPrice;
|
|
183
|
+
const fee = (amount) => (amount * FEE_BPS) / BPS;
|
|
184
|
+
/** Where a bond stands, in one line. */
|
|
185
|
+
export function bondLine(network, b, indexed, now) {
|
|
186
|
+
const head = `Bond No. ${b.deed} on task ${b.task}: ${usdg(network, b.amount)}`;
|
|
187
|
+
if (b.status === DeedStatus.Settled) {
|
|
188
|
+
if (b.remainderBps === Number(BPS))
|
|
189
|
+
return `${head}, delivered at ${when(b.settledAt)}: ${usdg(network, b.amount - fee(b.amount))} back after Shipped's 1% fee (bond claim ${b.task} collects it)`;
|
|
190
|
+
return `${head}, missed: paid to the poster ${b.refundTo} at ${when(b.settledAt)}`;
|
|
191
|
+
}
|
|
192
|
+
if (b.checkMetAt > 0n) {
|
|
193
|
+
const confirmAt = b.checkMetAt + CHECK_HOLD_SEC;
|
|
194
|
+
return `${head}, the Proof is in the index and the check was marked at ${when(b.checkMetAt)}; BrainChain confirms it ${now < confirmAt ? `after ${when(confirmAt)}` : "shortly"}`;
|
|
195
|
+
}
|
|
196
|
+
if (now > b.deadline)
|
|
197
|
+
return `${head}, deadline ${when(b.deadline)} passed with no Proof recorded; it settles to the poster ${b.refundTo} after ${when(b.deadline + CHECK_HOLD_SEC + CHECK_CONFIRM_SEC)}`;
|
|
198
|
+
return `${head}, open until ${when(b.deadline)}: ${indexed ? "the Proof is in the index and BrainChain marks the check shortly" : "waiting for the task to be paid and its Proof recorded"}`;
|
|
199
|
+
}
|
|
200
|
+
function closed(network, config) {
|
|
201
|
+
if (!config)
|
|
202
|
+
return `Bonds run on Robinhood Chain against Shipped's registry, which ${network.name} has no counterpart of. Set BRAINCHAIN_NETWORK=mainnet.`;
|
|
203
|
+
if (!config.proofIndex)
|
|
204
|
+
return "Bonds are not open yet: BrainChain's ProofIndex on Robinhood Chain, which a bond's check reads, is not deployed. A later release of brainchain-worker names it.";
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
/** Reads the task, says what the bond costs and what it does, and sends the approval and the bond. */
|
|
208
|
+
export async function bond(network, config, chain, task, options) {
|
|
209
|
+
const { log } = options;
|
|
210
|
+
const shut = closed(network, config);
|
|
211
|
+
if (shut)
|
|
212
|
+
throw new Error(shut);
|
|
213
|
+
const open = config;
|
|
214
|
+
const t = await chain.task(task);
|
|
215
|
+
if (!t)
|
|
216
|
+
throw new Error(`There is no task ${task} on ${network.name}.`);
|
|
217
|
+
if (t.poster.toLowerCase() === chain.account.toLowerCase())
|
|
218
|
+
throw new Error(`Task ${task} was posted by this wallet. A bond guarantees delivery to someone else: it earns no standing on a task the worker posted itself.`);
|
|
219
|
+
const hiredHere = t.status === Status.Hired && t.model.toLowerCase().endsWith(chain.account.slice(2).toLowerCase());
|
|
220
|
+
if (t.status !== Status.Open && !hiredHere)
|
|
221
|
+
throw new Error(`Task ${task} is not open and not hired to this worker, so there is nothing to bond.`);
|
|
222
|
+
const now = await chain.now();
|
|
223
|
+
if (t.deadline <= now)
|
|
224
|
+
throw new Error(`Task ${task}'s deadline, ${when(t.deadline)}, has passed.`);
|
|
225
|
+
if (options.amount < t.bounty)
|
|
226
|
+
throw new Error(`The bond must be at least the task's bounty, ${usdg(network, t.bounty)}; ${usdg(network, options.amount)} is under it.`);
|
|
227
|
+
if (options.amount < MIN_BOND)
|
|
228
|
+
throw new Error(`The bond must be at least ${usdg(network, MIN_BOND)}: under that, Shipped's fee and the gas take too much of it.`);
|
|
229
|
+
const existing = (await chain.bondsOn(task)).find((b) => b.status !== DeedStatus.Settled);
|
|
230
|
+
if (existing)
|
|
231
|
+
throw new Error(`This worker already has bond No. ${existing.deed} open on task ${task}. \`bond status ${task}\` shows where it stands.`);
|
|
232
|
+
// A task paid through the desk has the desk as its poster on the board; the bond pays the agent that paid.
|
|
233
|
+
const payer = await chain.payerOf(task);
|
|
234
|
+
if (payer?.toLowerCase() === chain.account.toLowerCase())
|
|
235
|
+
throw new Error(`Task ${task} was paid for by this wallet. A bond guarantees delivery to someone else: it earns no standing on a task the worker paid for itself.`);
|
|
236
|
+
const refundTo = payer ?? t.poster;
|
|
237
|
+
const [balance, allowance, gasPrice, eth] = await Promise.all([chain.usdgBalance(), chain.allowance(), chain.gasPrice(), chain.ethBalance()]);
|
|
238
|
+
const approves = allowance < options.amount;
|
|
239
|
+
const gas = bondGas(gasPrice, approves);
|
|
240
|
+
const terms = bondTerms(open, { id: task, refundTo, paidThroughDesk: payer !== undefined, deadline: t.deadline }, { address: network.contracts.taskBoard, chainId: network.chainId });
|
|
241
|
+
const back = options.amount - fee(options.amount);
|
|
242
|
+
log([
|
|
243
|
+
`Bond ${usdg(network, options.amount)} on task ${task} (bounty ${usdg(network, t.bounty)}, deadline ${when(t.deadline)}).`,
|
|
244
|
+
` Released by the task's Proof: ${usdg(network, back)} back after Shipped's 1% fee (${usdg(network, fee(options.amount))}), collected with \`bond claim ${task}\`.`,
|
|
245
|
+
` Missed: if no Proof is recorded by ${when(terms.deadline)}, the ${usdg(network, options.amount)} goes to ${refundTo}, ${payer ? "the agent that paid for the task" : "the poster"}.`,
|
|
246
|
+
` Gas now: about ${formatEther(gas)} ETH on ${network.proofs.name} for ${approves ? "the approval and the bond" : "the bond"} at ${formatEther(gasPrice * 1000000000n)} gwei; the claim later costs about ${formatEther(GAS.withdraw * gasPrice)} ETH.`,
|
|
247
|
+
].join("\n"));
|
|
248
|
+
if (balance < options.amount)
|
|
249
|
+
throw new Error(`${chain.account} holds ${usdg(network, balance)} on ${network.proofs.name}, under the ${usdg(network, options.amount)} to bond. Nothing was sent.`);
|
|
250
|
+
if (eth < gas)
|
|
251
|
+
throw new Error(`${chain.account} holds ${formatEther(eth)} ETH on ${network.proofs.name}, under the ${formatEther(gas)} ETH the transactions need. Nothing was sent.`);
|
|
252
|
+
if (!options.yes) {
|
|
253
|
+
if (!options.ask)
|
|
254
|
+
throw new Error("There is no terminal to confirm on. Run again with --yes to send without asking.");
|
|
255
|
+
const answer = (await options.ask("Send it? [y/N] ")).trim().toLowerCase();
|
|
256
|
+
if (answer !== "y" && answer !== "yes")
|
|
257
|
+
return log("Nothing sent.");
|
|
258
|
+
}
|
|
259
|
+
if (approves) {
|
|
260
|
+
log(`Approving ${usdg(network, options.amount)} for Shipped's registry...`);
|
|
261
|
+
await chain.approve(options.amount);
|
|
262
|
+
}
|
|
263
|
+
log(`Bonding ${usdg(network, options.amount)} on task ${task}...`);
|
|
264
|
+
const { deed, tx } = await chain.create(terms, options.amount);
|
|
265
|
+
log(`Bond No. ${deed} is open on Shipped (${tx}). Jev reads it when it hires; \`bond status ${task}\` follows it.`);
|
|
266
|
+
}
|
|
267
|
+
/** Prints where the worker's bonds on a task stand. */
|
|
268
|
+
export async function bondStatus(network, config, chain, task, log) {
|
|
269
|
+
const shut = closed(network, config);
|
|
270
|
+
if (shut)
|
|
271
|
+
throw new Error(shut);
|
|
272
|
+
const bonds = await chain.bondsOn(task);
|
|
273
|
+
if (bonds.length === 0)
|
|
274
|
+
return log(`${chain.account} has no bond on task ${task}.`);
|
|
275
|
+
const [now, indexed] = await Promise.all([chain.now(), chain.proofOf(task)]);
|
|
276
|
+
for (const b of bonds)
|
|
277
|
+
log(bondLine(network, b, indexed > 0n, now));
|
|
278
|
+
}
|
|
279
|
+
/** Collects what a settled bond left the worker: withdraw(USDG) on the registry. */
|
|
280
|
+
export async function bondClaim(network, config, chain, task, log) {
|
|
281
|
+
const shut = closed(network, config);
|
|
282
|
+
if (shut)
|
|
283
|
+
throw new Error(shut);
|
|
284
|
+
const [latest] = await chain.bondsOn(task);
|
|
285
|
+
if (!latest)
|
|
286
|
+
throw new Error(`${chain.account} has no bond on task ${task}.`);
|
|
287
|
+
if (latest.status !== DeedStatus.Settled) {
|
|
288
|
+
const [now, indexed] = await Promise.all([chain.now(), chain.proofOf(task)]);
|
|
289
|
+
throw new Error(`${bondLine(network, latest, indexed > 0n, now)}. It can be claimed once it has settled.`);
|
|
290
|
+
}
|
|
291
|
+
const owed = await chain.owed();
|
|
292
|
+
if (owed === 0n) {
|
|
293
|
+
return log(latest.remainderBps === Number(BPS) ? `Bond No. ${latest.deed} was delivered and its ${usdg(network, latest.amount - fee(latest.amount))} already withdrawn.` : `Bond No. ${latest.deed} was missed: its ${usdg(network, latest.amount)} went to the poster ${latest.refundTo}, so there is nothing to claim.`);
|
|
294
|
+
}
|
|
295
|
+
if ((await chain.ethBalance()) === 0n)
|
|
296
|
+
throw new Error(`${chain.account} has no ETH on ${network.proofs.name} to pay gas for the claim.`);
|
|
297
|
+
log(`Claiming ${usdg(network, owed)} from Shipped's registry on ${network.proofs.name}...`);
|
|
298
|
+
const { amount, tx } = await chain.withdraw();
|
|
299
|
+
log(`Paid ${usdg(network, amount)} to ${chain.account} (${tx}).`);
|
|
300
|
+
}
|
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,25 +1,31 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
2
3
|
import { verifyDecision } from "brainchain-verify";
|
|
3
4
|
import { createPublicClient, http } from "viem";
|
|
4
|
-
import { parseUnits } from "viem";
|
|
5
|
+
import { formatEther, parseUnits, zeroAddress } from "viem";
|
|
5
6
|
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
|
|
6
7
|
import { brainchainTransport, createChain, faucetReopens, reason, requireChainId, unhealthy } from "./chain.js";
|
|
8
|
+
import { bond, bondClaim, bondStatus, createBondChain } from "./bond.js";
|
|
7
9
|
import { assetOf, bridge, createBridgeChain } from "./bridge.js";
|
|
8
10
|
import { claim, createIncome } from "./claim.js";
|
|
9
11
|
import { createProofsChain, createSettlementWatch, MAX_MINT, mint } from "./mint.js";
|
|
10
12
|
import { chainOf, resolveNetwork } from "./network.js";
|
|
11
13
|
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";
|
|
14
|
+
import { deliveries, deliveriesLeft, ethNeeded, flightLine, gasHelp, gasLine, hireable, joinLimits, shortOfGas, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
|
|
13
15
|
import { openStore, readWatched, writeWatched } from "./state.js";
|
|
14
16
|
import { clearKeyFromEnvironment, keyConfigured, keyText, writeKeyFile } from "./key.js";
|
|
15
17
|
import { createCreditChain, credit } from "./credit.js";
|
|
16
18
|
import { appeal, createDisputeChain, dispute } from "./dispute.js";
|
|
17
19
|
import { createReporter, createWatch, createWatchChain, requireWatcher } from "./watch.js";
|
|
18
|
-
import { createWork, firstProgram,
|
|
20
|
+
import { createWork, firstProgram, npxUnderSupervisor, programFound, shellCommand, stopLeftover, stopper, unseen, watchStarter } from "./work.js";
|
|
19
21
|
/** How many of the latest decisions a first watch --once checks. */
|
|
20
22
|
const FIRST_RUN_DECISIONS = 10n;
|
|
21
23
|
/** How long an idle run goes without a line before it says it is still waiting. */
|
|
22
24
|
const HEARTBEAT_MS = 3_600_000;
|
|
25
|
+
/** Below this many deliveries' worth of gas, run warns after each delivery. */
|
|
26
|
+
const LOW_DELIVERIES = 3n;
|
|
27
|
+
/** How often a run that cannot be hired yet reads again whether it can. */
|
|
28
|
+
const HIREABLE_CHECK_MS = 300_000;
|
|
23
29
|
/** A published record's bytes as the service serves them, capped at a megabyte. */
|
|
24
30
|
async function fetchRecord(uri) {
|
|
25
31
|
const response = await fetch(uri, { signal: AbortSignal.timeout(20_000) });
|
|
@@ -34,19 +40,20 @@ const { version } = createRequire(import.meta.url)("../package.json");
|
|
|
34
40
|
/** Each command's lines in the usage text, in the order the text lists them. */
|
|
35
41
|
const COMMAND_USAGE = {
|
|
36
42
|
"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
|
|
43
|
+
to a new file only you can read and prints the file's path in
|
|
44
|
+
place of the key.`,
|
|
38
45
|
join: ` brainchain-worker join --name <name> --about <text> [--price <USDG>] [--agent-id <id>]
|
|
39
46
|
Stake the minimum and apply to take tasks. The name is at
|
|
40
47
|
most 60 characters, the description at most 500. --agent-id
|
|
41
48
|
(mainnet) names the worker's ERC-8004 agent on Robinhood Chain,
|
|
42
49
|
so its BrainChain record is written to that identity.`,
|
|
43
|
-
run: ` brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once] [--
|
|
50
|
+
run: ` brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once] [--mint]
|
|
44
51
|
Do every task Jev hires the worker for: the command reads the
|
|
45
52
|
task on stdin and prints the deliverable on stdout, within
|
|
46
53
|
--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
|
|
54
|
+
each task settles. --mint also mints the Proof of each task it is
|
|
55
|
+
paid for.`,
|
|
56
|
+
status: ` brainchain-worker status Stake, wallet, application, record, earnings and gas, with where each
|
|
50
57
|
task in flight stands.`,
|
|
51
58
|
price: ` brainchain-worker price <USDG> Change your asking price per task.`,
|
|
52
59
|
mint: ` brainchain-worker mint [--count <n>] [--dry]
|
|
@@ -54,6 +61,13 @@ const COMMAND_USAGE = {
|
|
|
54
61
|
--count, at 0.001 WETH each on Robinhood Chain. --dry sends nothing.`,
|
|
55
62
|
claim: ` brainchain-worker claim [--dry] Claim the ETH due on the worker's Proofs, on Robinhood Chain.
|
|
56
63
|
--dry lists what is due and sends nothing.`,
|
|
64
|
+
bond: ` brainchain-worker bond <task> <USDG> [--yes] | bond status <task> | bond claim <task>
|
|
65
|
+
Bond USDG on Shipped behind a task, at least its bounty and 2 USDG:
|
|
66
|
+
the task's Proof releases it less Shipped's 1% fee, and a task that
|
|
67
|
+
is not paid by its deadline pays the bond to the poster. Shows the
|
|
68
|
+
cost and asks before sending; --yes sends without asking. status
|
|
69
|
+
says where the bond stands and claim collects what a settled one
|
|
70
|
+
left the worker. Mainnet.`,
|
|
57
71
|
bridge: ` brainchain-worker bridge --eth <ETH> | --stake <amount> | --bounty <amount> [--dry]
|
|
58
72
|
Move ETH for gas, the stake token or the bounty token from Robinhood
|
|
59
73
|
Chain to the worker's address on BrainChain; it arrives in about a
|
|
@@ -84,8 +98,9 @@ const COMMAND_USAGE = {
|
|
|
84
98
|
window. Jev rules on it; a confident ruling decides.`,
|
|
85
99
|
appeal: ` brainchain-worker appeal <task> Appeal the ruling on a task, when it went against the worker, within the
|
|
86
100
|
appeal window, with the bond the task's terms fix (a quarter of the
|
|
87
|
-
bounty)
|
|
88
|
-
rules the
|
|
101
|
+
bounty), claimed from the test USDG faucet when the wallet holds less.
|
|
102
|
+
Jev rules again; the bond comes back unless Jev confidently rules the
|
|
103
|
+
same way. Testnet only for now.`,
|
|
89
104
|
leave: ` brainchain-worker leave Start unstaking (3-day cooldown).`,
|
|
90
105
|
withdraw: ` brainchain-worker withdraw Withdraw stake once the cooldown has passed.`,
|
|
91
106
|
};
|
|
@@ -119,12 +134,14 @@ export class UsageError extends Error {
|
|
|
119
134
|
const FLAGS = {
|
|
120
135
|
"new-key": { file: false },
|
|
121
136
|
join: { name: false, about: false, price: false, "agent-id": false },
|
|
122
|
-
|
|
137
|
+
// --no-mint is still accepted from before minting became opt-in; it changes nothing.
|
|
138
|
+
run: { exec: false, format: false, timeout: false, once: true, mint: true, "no-mint": true },
|
|
123
139
|
status: {},
|
|
124
140
|
price: {},
|
|
125
141
|
leave: {},
|
|
126
142
|
withdraw: {},
|
|
127
143
|
claim: { dry: true },
|
|
144
|
+
bond: { yes: true },
|
|
128
145
|
mint: { count: false, dry: true },
|
|
129
146
|
bridge: { eth: false, stake: false, bounty: false, dry: true },
|
|
130
147
|
watch: { veto: true, once: true },
|
|
@@ -134,7 +151,7 @@ const FLAGS = {
|
|
|
134
151
|
dispute: {},
|
|
135
152
|
appeal: {},
|
|
136
153
|
};
|
|
137
|
-
const POSITIONAL = { price: 1, proposal: 1, dispute: 1, appeal: 1 };
|
|
154
|
+
const POSITIONAL = { price: 1, proposal: 1, dispute: 1, appeal: 1, bond: 2 };
|
|
138
155
|
/** A command's flags; a name from argv can match what every object inherits, such as constructor. */
|
|
139
156
|
const flagsOf = (command) => (Object.hasOwn(FLAGS, command) ? FLAGS[command] : undefined);
|
|
140
157
|
export function parse(command, args) {
|
|
@@ -193,7 +210,7 @@ function signer() {
|
|
|
193
210
|
const raw = keyText()?.trim();
|
|
194
211
|
if (!raw)
|
|
195
212
|
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.
|
|
213
|
+
const hex = raw.replace(/^0x/i, "");
|
|
197
214
|
if (!/^[0-9a-fA-F]{64}$/.test(hex))
|
|
198
215
|
throw new Error("The worker's key must be 64 hex characters, with or without 0x");
|
|
199
216
|
const key = `0x${hex.toLowerCase()}`;
|
|
@@ -214,12 +231,12 @@ function price(value) {
|
|
|
214
231
|
}
|
|
215
232
|
const RUN = '`npx brainchain-worker run --exec "<command>"`';
|
|
216
233
|
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.
|
|
234
|
+
async function noGas(chain, network) {
|
|
235
|
+
return new Error(`${chain.account} has no ETH on ${network.name} for gas. ${gasHelp(network, chain.account, 0n, await chain.sent())}.`);
|
|
219
236
|
}
|
|
220
237
|
async function needGas(chain, network) {
|
|
221
238
|
if ((await chain.ethBalance()) === 0n)
|
|
222
|
-
throw noGas(chain, network);
|
|
239
|
+
throw await noGas(chain, network);
|
|
223
240
|
}
|
|
224
241
|
export async function join(chain, network, values) {
|
|
225
242
|
const name = values.get("name")?.trim() ?? "";
|
|
@@ -229,15 +246,18 @@ export async function join(chain, network, values) {
|
|
|
229
246
|
if (name.length > 60)
|
|
230
247
|
throw new UsageError("--name is at most 60 characters");
|
|
231
248
|
// A name that hides characters or turns text around can read as another, Jev's included.
|
|
232
|
-
|
|
233
|
-
|
|
249
|
+
const nameHides = unseen(name);
|
|
250
|
+
if (nameHides)
|
|
251
|
+
throw new UsageError(`--name must be one line with no invisible or direction-changing characters, and it holds ${nameHides}`);
|
|
234
252
|
if (!about) {
|
|
235
253
|
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
254
|
}
|
|
237
255
|
if (about.length > 500)
|
|
238
256
|
throw new UsageError("--about is at most 500 characters");
|
|
239
|
-
|
|
240
|
-
|
|
257
|
+
// A description may run over several lines.
|
|
258
|
+
const aboutHides = unseen(about.replace(/[\t\n]/g, " "));
|
|
259
|
+
if (aboutHides)
|
|
260
|
+
throw new UsageError(`--about must have no invisible or direction-changing characters, and it holds ${aboutHides}`);
|
|
241
261
|
const agentId = values.get("agent-id");
|
|
242
262
|
if (agentId !== undefined && !/^(0|[1-9]\d{0,14})$/.test(agentId))
|
|
243
263
|
throw new UsageError("--agent-id is the worker's ERC-8004 agent id, a whole number");
|
|
@@ -261,12 +281,19 @@ export async function join(chain, network, values) {
|
|
|
261
281
|
}
|
|
262
282
|
// A worker that has staked keeps its price unless --price changes it.
|
|
263
283
|
const asking = requested ?? (w.registered ? w.price : price(DEFAULT_PRICE));
|
|
264
|
-
const [gas, gasPrice, bond, balance, timelock] = await Promise.all([
|
|
284
|
+
const [gas, gasPrice, bond, balance, timelock, sent] = await Promise.all([
|
|
285
|
+
chain.ethBalance(),
|
|
286
|
+
chain.gasPrice(),
|
|
287
|
+
chain.bond(),
|
|
288
|
+
chain.stakeBalance(),
|
|
289
|
+
chain.timelock(),
|
|
290
|
+
chain.sent(),
|
|
291
|
+
]);
|
|
265
292
|
const need = toStake + (applying ? bond : 0n);
|
|
266
293
|
const claims = balance < need && !network.buyStake;
|
|
267
294
|
// Checked before anything is sent: a wallet that runs out partway leaves the stake placed and the application unsent.
|
|
268
295
|
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;
|
|
296
|
+
const lacksGas = gas < gasNeed ? `${chain.account} holds ${shortOfGas(network, chain.account, gas, sent, gasNeed, "join")}` : undefined;
|
|
270
297
|
if (balance < need && network.buyStake) {
|
|
271
298
|
const short = shortOfStake(network, w, balance, need, await chain.now());
|
|
272
299
|
// Both needs at once, so a new worker does not bridge ETH only to learn it needs the stake token too.
|
|
@@ -320,9 +347,10 @@ export async function join(chain, network, values) {
|
|
|
320
347
|
"the application with `npx brainchain-worker status`. New workers are offered house tasks first, which BrainChain pays for.");
|
|
321
348
|
}
|
|
322
349
|
async function status(chain, network) {
|
|
323
|
-
const [gas, gasPrice, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
|
|
350
|
+
const [gas, gasPrice, sent, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
|
|
324
351
|
chain.ethBalance(),
|
|
325
352
|
chain.gasPrice(),
|
|
353
|
+
chain.sent(),
|
|
326
354
|
snapshot(chain),
|
|
327
355
|
chain.held(),
|
|
328
356
|
chain.record(),
|
|
@@ -342,7 +370,7 @@ async function status(chain, network) {
|
|
|
342
370
|
Price ${w.registered ? `${usdg(network, w.price)} per task` : "not set until you join"}
|
|
343
371
|
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
372
|
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))}`);
|
|
373
|
+
Gas ${gasLine(network, chain.account, s, gas, gasPrice, !network.buyStake && stakeBalance < s.toStake + (s.applying ? bond : 0n), sent)}`);
|
|
346
374
|
}
|
|
347
375
|
async function run(network, { key, account }, values, once, mints) {
|
|
348
376
|
const exec = values.get("exec");
|
|
@@ -379,9 +407,23 @@ async function run(network, { key, account }, values, once, mints) {
|
|
|
379
407
|
stopping: stop.stopping,
|
|
380
408
|
// A cron job sees a failed task as a failed run.
|
|
381
409
|
failed: once ? () => void (process.exitCode = 1) : undefined,
|
|
382
|
-
delivered
|
|
383
|
-
|
|
410
|
+
delivered(task) {
|
|
411
|
+
settlements.track(task);
|
|
412
|
+
void lowGas();
|
|
413
|
+
},
|
|
414
|
+
gasHelp: async () => gasHelp(network, account.address, 0n, await chain.sent()),
|
|
384
415
|
});
|
|
416
|
+
// The drip pays for only a few deliveries, so the log says when the wallet is down to its last ones.
|
|
417
|
+
const lowGas = async () => {
|
|
418
|
+
const [gas, price, sent] = await Promise.all([chain.ethBalance(), chain.gasPrice(), chain.sent()]).catch(() => []);
|
|
419
|
+
if (gas === undefined || price === undefined || sent === undefined)
|
|
420
|
+
return;
|
|
421
|
+
const left = deliveriesLeft(gas, price);
|
|
422
|
+
if (left >= LOW_DELIVERIES)
|
|
423
|
+
return;
|
|
424
|
+
const enough = left === 0n ? "not enough for another delivery" : `enough for about ${left} more ${left === 1n ? "delivery" : "deliveries"}`;
|
|
425
|
+
log(`warning: the wallet holds ${formatEther(gas)} ETH for gas, ${enough}. ${gasHelp(network, account.address, gas, sent)}.`);
|
|
426
|
+
};
|
|
385
427
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
386
428
|
process.on(signal, stop.signal);
|
|
387
429
|
if (process.env.npm_execpath !== undefined) {
|
|
@@ -406,12 +448,14 @@ async function run(network, { key, account }, values, once, mints) {
|
|
|
406
448
|
log(`could not read the wallet's ETH balance: ${reason(error)}`);
|
|
407
449
|
}
|
|
408
450
|
if (gas === 0n)
|
|
409
|
-
throw noGas(chain, network);
|
|
410
|
-
const
|
|
451
|
+
throw await noGas(chain, network);
|
|
452
|
+
const held = gas;
|
|
453
|
+
const [line, sick, left] = await Promise.all([
|
|
411
454
|
snapshot(chain).then((s) => hireable(s, network, "run"), (error) => `could not check (${reason(error)})`),
|
|
412
455
|
unhealthy(network.service),
|
|
456
|
+
held === undefined ? undefined : chain.gasPrice().then((price) => deliveriesLeft(held, price), () => undefined),
|
|
413
457
|
]);
|
|
414
|
-
log(`worker ${chain.account} on ${network.name}; hireable: ${line}`);
|
|
458
|
+
log(`worker ${chain.account} on ${network.name}; hireable: ${line}${left === undefined ? "" : `; gas for ${deliveries(left)}`}`);
|
|
415
459
|
if (sick)
|
|
416
460
|
log(`warning: ${sick}`);
|
|
417
461
|
if (once) {
|
|
@@ -428,12 +472,16 @@ async function run(network, { key, account }, values, once, mints) {
|
|
|
428
472
|
log("waiting for tasks (Ctrl-C to stop)");
|
|
429
473
|
// Minting waits on receipts from another chain, so it runs beside the work, one round at a time.
|
|
430
474
|
let minting;
|
|
475
|
+
const hireChange = hireWatch(line, () => snapshot(chain).then((s) => hireable(s, network, "run"), () => undefined));
|
|
431
476
|
while (!stop.stopping()) {
|
|
432
477
|
await work.round().catch((error) => log(`round failed: ${reason(error)}`));
|
|
433
478
|
minting ??= settlements
|
|
434
479
|
.round()
|
|
435
480
|
.catch((error) => log(`settlement round failed: ${reason(error)}`))
|
|
436
481
|
.finally(() => (minting = undefined));
|
|
482
|
+
const changed = stop.stopping() ? undefined : await hireChange();
|
|
483
|
+
if (changed !== undefined)
|
|
484
|
+
log(`hireable: ${changed}`);
|
|
437
485
|
if (quietFor() >= HEARTBEAT_MS && !work.busy() && !stop.stopping()) {
|
|
438
486
|
log(`still waiting for tasks; hireable: ${await snapshot(chain).then((s) => hireable(s, network, "run"), (error) => `could not check (${reason(error)})`)}`);
|
|
439
487
|
}
|
|
@@ -443,6 +491,26 @@ async function run(network, { key, account }, values, once, mints) {
|
|
|
443
491
|
await minting;
|
|
444
492
|
log("stopped");
|
|
445
493
|
}
|
|
494
|
+
/**
|
|
495
|
+
* Reads again, every few minutes until the worker can be hired, whether it can,
|
|
496
|
+
* and returns the new answer when it changed. A run started while Jev decides
|
|
497
|
+
* on the application then says when Jev allows the worker, and when its
|
|
498
|
+
* timelock has passed.
|
|
499
|
+
*/
|
|
500
|
+
export function hireWatch(first, check, everyMs = HIREABLE_CHECK_MS, now = Date.now) {
|
|
501
|
+
let last = first;
|
|
502
|
+
let checkedAt = now();
|
|
503
|
+
return async () => {
|
|
504
|
+
if (last === "yes" || now() - checkedAt < everyMs)
|
|
505
|
+
return undefined;
|
|
506
|
+
checkedAt = now();
|
|
507
|
+
const line = await check();
|
|
508
|
+
if (line === undefined || line === last)
|
|
509
|
+
return undefined;
|
|
510
|
+
last = line;
|
|
511
|
+
return line;
|
|
512
|
+
};
|
|
513
|
+
}
|
|
446
514
|
/** 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
515
|
export function timedLog(write, now = Date.now) {
|
|
448
516
|
let last = now();
|
|
@@ -454,6 +522,41 @@ export function timedLog(write, now = Date.now) {
|
|
|
454
522
|
quietFor: () => now() - last,
|
|
455
523
|
};
|
|
456
524
|
}
|
|
525
|
+
/** The task id and amount a bond command names, or what is wrong with them. */
|
|
526
|
+
export function bondArgs(positional) {
|
|
527
|
+
const [first, second] = positional;
|
|
528
|
+
const taskId = (value, usage) => {
|
|
529
|
+
if (value === undefined || !/^[1-9]\d{0,17}$/.test(value))
|
|
530
|
+
throw new UsageError(usage);
|
|
531
|
+
return BigInt(value);
|
|
532
|
+
};
|
|
533
|
+
if (first === "status" || first === "claim")
|
|
534
|
+
return { action: first, task: taskId(second, `Usage: brainchain-worker bond ${first} <task>, such as bond ${first} 12`) };
|
|
535
|
+
const task = taskId(first, "Usage: brainchain-worker bond <task> <USDG>, such as bond 12 5, or bond status <task>, or bond claim <task>");
|
|
536
|
+
if (second === undefined || !/^\d{1,9}(\.\d{1,6})?$/.test(second) || Number(second) <= 0) {
|
|
537
|
+
throw new UsageError("The amount is in USDG, above zero with at most 6 decimals, such as bond 12 5");
|
|
538
|
+
}
|
|
539
|
+
return { action: "bond", task, amount: parseUnits(second, 6) };
|
|
540
|
+
}
|
|
541
|
+
async function bondCommand(network, positional, yes) {
|
|
542
|
+
const args = bondArgs(positional);
|
|
543
|
+
const config = network.proofs.bonds;
|
|
544
|
+
const chain = createBondChain(network, config ?? { registry: zeroAddress, check: zeroAddress, usdg: zeroAddress }, signer().account);
|
|
545
|
+
if (args.action !== "bond")
|
|
546
|
+
return (args.action === "status" ? bondStatus : bondClaim)(network, config, chain, args.task, console.log);
|
|
547
|
+
const ask = process.stdin.isTTY
|
|
548
|
+
? async (question) => {
|
|
549
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
550
|
+
try {
|
|
551
|
+
return await rl.question(question);
|
|
552
|
+
}
|
|
553
|
+
finally {
|
|
554
|
+
rl.close();
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
: undefined;
|
|
558
|
+
return bond(network, config, chain, args.task, { amount: args.amount, yes, ...(ask ? { ask } : {}), log: console.log });
|
|
559
|
+
}
|
|
457
560
|
/** Where watch --once keeps its place: a key's place counts toward its watchtower mark, so each key has its own, apart from runs without one. */
|
|
458
561
|
export const watchPlace = (log, key) => (key === undefined ? log : `${log}.${key}`);
|
|
459
562
|
/** What watch says at start. Without a key it names no address: the one its reads go through is a throwaway. */
|
|
@@ -496,6 +599,8 @@ export async function main(argv) {
|
|
|
496
599
|
if (command === "claim") {
|
|
497
600
|
return claim(network, (income) => createIncome(network.proofs, income, signer().account), { dry: switches.has("dry"), log: console.log });
|
|
498
601
|
}
|
|
602
|
+
if (command === "bond")
|
|
603
|
+
return bondCommand(network, positional, switches.has("yes"));
|
|
499
604
|
if (command === "mint") {
|
|
500
605
|
const count = values.get("count");
|
|
501
606
|
if (count !== undefined && !(/^\d{1,2}$/.test(count) && Number(count) >= 1 && Number(count) <= MAX_MINT)) {
|
|
@@ -602,7 +707,7 @@ export async function main(argv) {
|
|
|
602
707
|
return propose(network, createProposals(network, worker.account), call, uri, { dry: switches.has("dry"), log: console.log });
|
|
603
708
|
}
|
|
604
709
|
if (command === "run")
|
|
605
|
-
return run(network, worker, values, switches.has("once"),
|
|
710
|
+
return run(network, worker, values, switches.has("once"), switches.has("mint"));
|
|
606
711
|
const chain = createChain(network, worker.account);
|
|
607
712
|
switch (command) {
|
|
608
713
|
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: {
|
|
@@ -75,6 +78,12 @@ export const MAINNET = {
|
|
|
75
78
|
weth: "0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73",
|
|
76
79
|
gas: "Send it a little ETH there for gas",
|
|
77
80
|
income: "0xeb1b4168D4b87D3d9F0D64E13B7071251BBB63b7",
|
|
81
|
+
// Shipped's registry and StaticCallCheck. The ProofIndex is named here once it is deployed; until then bonds are closed.
|
|
82
|
+
bonds: {
|
|
83
|
+
registry: "0xD5Da523ee49CB7Fe855B0C6fa38707190A9D531F",
|
|
84
|
+
check: "0x2e7C4F0F2e6079C3B5F7Cd2440308234CBDc537D",
|
|
85
|
+
usdg: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168",
|
|
86
|
+
},
|
|
78
87
|
},
|
|
79
88
|
identity: "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432",
|
|
80
89
|
priors: { pool: "0x281210097f0de7A8FB6F87310AF0f089c9C8DE21", root: 20423n },
|
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.16.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",
|