brainchain-worker 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/claim.js +4 -4
- package/dist/cli.js +409 -0
- package/dist/index.js +2 -370
- package/dist/mint.js +3 -3
- package/dist/network.js +11 -2
- package/dist/propose.js +318 -0
- package/dist/standing.js +10 -3
- package/dist/state.js +10 -8
- package/package.json +1 -1
package/dist/propose.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { BaseError, createPublicClient, createWalletClient, decodeFunctionData, encodeAbiParameters, encodeFunctionData, getAddress, http, isAddress, isHex, keccak256, parseAbi, parseEventLogs, size, } from "viem";
|
|
2
|
+
import { bondsAbi, erc20Abi, executorAbi, RECEIPT_MS } from "./chain.js";
|
|
3
|
+
import { chainOf } from "./network.js";
|
|
4
|
+
import { stake, when } from "./standing.js";
|
|
5
|
+
/** Every call the executor can be asked to make, by the contract it calls. */
|
|
6
|
+
export const GOVERNED = {
|
|
7
|
+
board: parseAbi([
|
|
8
|
+
"struct Config { uint16 hireThresholdBps; uint16 gradeThresholdBps; uint16 ruleThresholdBps; uint64 minDuration; uint64 maxDuration; uint64 gradeWindow; uint64 disputeWindow; uint64 ruleWindow; uint128 minBounty; }",
|
|
9
|
+
"function setConfig(Config config)",
|
|
10
|
+
"function setFee(uint16 feeBps, address feeRouter)",
|
|
11
|
+
"function setPaused(bool paused)",
|
|
12
|
+
"function setWorkerAllowed(address account, bool allowed)",
|
|
13
|
+
]),
|
|
14
|
+
executor: parseAbi([
|
|
15
|
+
"function setParameters(uint16 changeThresholdBps, uint64 timelock, uint32 houseTasksPerDay, uint128 maxHouseBounty, uint128 houseSpendPerDay)",
|
|
16
|
+
"function transfer(address to, uint256 amount)",
|
|
17
|
+
]),
|
|
18
|
+
registry: parseAbi(["function setModel(bytes32 id, address account, uint128 price, bool active)"]),
|
|
19
|
+
};
|
|
20
|
+
const TARGETS = Object.keys(GOVERNED);
|
|
21
|
+
/** What the brain reads from a proposal's URI, cut to the lengths it keeps. */
|
|
22
|
+
export const LIMITS = { title: 120, rationale: 1_500, current: 300, proposed: 300 };
|
|
23
|
+
const proposalsAbi = parseAbi([
|
|
24
|
+
"event Expired(uint256 indexed id)",
|
|
25
|
+
"function decideWithin() view returns (uint64)",
|
|
26
|
+
]);
|
|
27
|
+
const executorReadsAbi = parseAbi([
|
|
28
|
+
"function vetoedCall(bytes32) view returns (bool)",
|
|
29
|
+
"function changeThresholdBps() view returns (uint16)",
|
|
30
|
+
]);
|
|
31
|
+
/** Mirrors ProposalBonds.State. */
|
|
32
|
+
const State = { None: 0, Open: 1, Returned: 2, Burned: 3 };
|
|
33
|
+
export class ProposalError extends Error {
|
|
34
|
+
}
|
|
35
|
+
function functions(target) {
|
|
36
|
+
return GOVERNED[target].filter((item) => item.type === "function").map((item) => item.name);
|
|
37
|
+
}
|
|
38
|
+
/** A JSON value as the ABI parameter it is passed for: integers become bigints, tuples objects. */
|
|
39
|
+
export function coerce(param, value) {
|
|
40
|
+
const name = param.name ?? param.type;
|
|
41
|
+
const fail = (expected) => new ProposalError(`${name} must be ${expected}, not ${JSON.stringify(value)}`);
|
|
42
|
+
if (/^u?int\d*$/.test(param.type)) {
|
|
43
|
+
const signed = param.type.startsWith("int");
|
|
44
|
+
if (typeof value === "number" && Number.isSafeInteger(value))
|
|
45
|
+
return BigInt(value);
|
|
46
|
+
if (typeof value === "string" && (signed ? /^-?\d+$/ : /^\d+$/).test(value))
|
|
47
|
+
return BigInt(value);
|
|
48
|
+
throw fail(`a whole number${signed ? "" : " from 0"}, as a JSON number or a string of digits`);
|
|
49
|
+
}
|
|
50
|
+
if (param.type === "bool") {
|
|
51
|
+
if (typeof value === "boolean")
|
|
52
|
+
return value;
|
|
53
|
+
throw fail("true or false");
|
|
54
|
+
}
|
|
55
|
+
if (param.type === "address") {
|
|
56
|
+
if (typeof value === "string" && isAddress(value, { strict: false }))
|
|
57
|
+
return getAddress(value);
|
|
58
|
+
throw fail("an address");
|
|
59
|
+
}
|
|
60
|
+
const bytes = /^bytes(\d+)$/.exec(param.type);
|
|
61
|
+
if (bytes) {
|
|
62
|
+
if (typeof value === "string" && isHex(value) && size(value) === Number(bytes[1]))
|
|
63
|
+
return value.toLowerCase();
|
|
64
|
+
throw fail(`${bytes[1]} bytes of hex`);
|
|
65
|
+
}
|
|
66
|
+
if (param.type === "tuple" && "components" in param) {
|
|
67
|
+
const parts = param.components;
|
|
68
|
+
if (Array.isArray(value) && value.length === parts.length) {
|
|
69
|
+
return Object.fromEntries(parts.map((part, i) => [part.name, coerce(part, value[i])]));
|
|
70
|
+
}
|
|
71
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
72
|
+
const given = value;
|
|
73
|
+
const extra = Object.keys(given).filter((key) => !parts.some((part) => part.name === key));
|
|
74
|
+
if (extra.length > 0)
|
|
75
|
+
throw new ProposalError(`${name} has no field ${extra[0]}; its fields are ${parts.map((p) => p.name).join(", ")}`);
|
|
76
|
+
return Object.fromEntries(parts.map((part) => {
|
|
77
|
+
if (!(part.name in given))
|
|
78
|
+
throw new ProposalError(`${name} is missing ${part.name}; give every field: ${parts.map((p) => p.name).join(", ")}`);
|
|
79
|
+
return [part.name, coerce(part, given[part.name])];
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
throw fail(`an object with ${parts.map((p) => p.name).join(", ")}`);
|
|
83
|
+
}
|
|
84
|
+
throw fail(param.type);
|
|
85
|
+
}
|
|
86
|
+
const show = (value) => {
|
|
87
|
+
if (typeof value === "bigint")
|
|
88
|
+
return value.toString();
|
|
89
|
+
if (Array.isArray(value))
|
|
90
|
+
return `(${value.map(show).join(", ")})`;
|
|
91
|
+
if (typeof value === "object" && value !== null)
|
|
92
|
+
return `{${Object.entries(value).map(([k, v]) => `${k}: ${show(v)}`).join(", ")}}`;
|
|
93
|
+
return String(value);
|
|
94
|
+
};
|
|
95
|
+
function resolveTarget(network, target) {
|
|
96
|
+
const addresses = { board: network.contracts.taskBoard, executor: network.contracts.executor, registry: network.contracts.registry };
|
|
97
|
+
const named = TARGETS.find((t) => t === target || addresses[t].toLowerCase() === target.toLowerCase());
|
|
98
|
+
if (!named)
|
|
99
|
+
throw new ProposalError(`--target is board, executor or registry, not "${target}"`);
|
|
100
|
+
return { target: named, address: addresses[named] };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The call a proposal asks the executor to make, from --call and --args or
|
|
104
|
+
* from raw --data, refused here when the executor would refuse it anyway.
|
|
105
|
+
*/
|
|
106
|
+
export function buildCall(network, proposer, input) {
|
|
107
|
+
const { target, address } = resolveTarget(network, input.target);
|
|
108
|
+
const abi = GOVERNED[target];
|
|
109
|
+
const offered = `The ${target} takes ${functions(target).join(", ")}`;
|
|
110
|
+
let data;
|
|
111
|
+
if (input.data !== undefined) {
|
|
112
|
+
if (input.call !== undefined || input.args !== undefined)
|
|
113
|
+
throw new ProposalError("Give either --data or --call with --args, not both");
|
|
114
|
+
if (!isHex(input.data) || input.data.length < 10)
|
|
115
|
+
throw new ProposalError("--data must be the call's hex calldata, starting 0x");
|
|
116
|
+
data = input.data.toLowerCase();
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
if (!input.call)
|
|
120
|
+
throw new ProposalError(`--call is required: the function to call. ${offered}.`);
|
|
121
|
+
const fn = abi.find((item) => item.type === "function" && item.name === input.call);
|
|
122
|
+
if (!fn || fn.type !== "function")
|
|
123
|
+
throw new ProposalError(`${offered}, not ${input.call}. setGuardian and setOwner are never queued.`);
|
|
124
|
+
let args;
|
|
125
|
+
try {
|
|
126
|
+
args = JSON.parse(input.args ?? "[]");
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw new ProposalError(`--args must be a JSON array, such as '[300, "0x…"]'`);
|
|
130
|
+
}
|
|
131
|
+
if (!Array.isArray(args) || args.length !== fn.inputs.length) {
|
|
132
|
+
throw new ProposalError(`${fn.name} takes ${fn.inputs.length} argument${fn.inputs.length === 1 ? "" : "s"} (${fn.inputs.map((p) => p.name).join(", ")}), given as a JSON array in --args`);
|
|
133
|
+
}
|
|
134
|
+
data = encodeFunctionData({ abi, functionName: fn.name, args: fn.inputs.map((param, i) => coerce(param, args[i])) });
|
|
135
|
+
}
|
|
136
|
+
let decoded;
|
|
137
|
+
try {
|
|
138
|
+
decoded = decodeFunctionData({ abi, data });
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
throw new ProposalError(`${offered}; the selector ${data.slice(0, 10)} is none of them`);
|
|
142
|
+
}
|
|
143
|
+
if (encodeFunctionData({ abi, functionName: decoded.functionName, args: decoded.args }).toLowerCase() !== data) {
|
|
144
|
+
throw new ProposalError("--data has bytes beyond the call's arguments");
|
|
145
|
+
}
|
|
146
|
+
const args = decoded.args ?? [];
|
|
147
|
+
if (target === "board" && decoded.functionName === "setWorkerAllowed" && args[1] === true && String(args[0]).toLowerCase() === proposer.toLowerCase()) {
|
|
148
|
+
throw new ProposalError("Allowing your own wallet to work is an application: run join, which stakes and applies in one go");
|
|
149
|
+
}
|
|
150
|
+
return { target, address, data, text: `${decoded.functionName}(${args.map(show).join(", ")})` };
|
|
151
|
+
}
|
|
152
|
+
/** The proposal's URI: base64 JSON the brain shows Jev as the proposer's case. */
|
|
153
|
+
export function caseURI(call, input) {
|
|
154
|
+
const title = input.title.trim();
|
|
155
|
+
const rationale = input.rationale.trim();
|
|
156
|
+
if (!title)
|
|
157
|
+
throw new ProposalError(`--title is required, up to ${LIMITS.title} characters`);
|
|
158
|
+
if (!rationale)
|
|
159
|
+
throw new ProposalError(`--rationale is required: why the change is better, up to ${LIMITS.rationale} characters. Jev reads it to decide.`);
|
|
160
|
+
const body = { title, rationale, current: input.current?.trim() ?? "", proposed: input.proposed?.trim() || call.text };
|
|
161
|
+
for (const key of Object.keys(LIMITS)) {
|
|
162
|
+
if (body[key].length > LIMITS[key])
|
|
163
|
+
throw new ProposalError(`--${key} is at most ${LIMITS[key]} characters`);
|
|
164
|
+
}
|
|
165
|
+
return `data:application/json;base64,${Buffer.from(JSON.stringify(body)).toString("base64")}`;
|
|
166
|
+
}
|
|
167
|
+
const LOG_RANGE = 50000n;
|
|
168
|
+
export function createProposals(network, account) {
|
|
169
|
+
const chain = chainOf(network);
|
|
170
|
+
const transport = http(network.rpc, { timeout: 20_000, retryCount: 3 });
|
|
171
|
+
const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
|
|
172
|
+
const wallet = createWalletClient({ account, chain, transport });
|
|
173
|
+
const { bonds, executor, stakeToken } = network.contracts;
|
|
174
|
+
const proposals = { address: bonds, abi: [...bondsAbi, ...proposalsAbi] };
|
|
175
|
+
async function scan(fetch) {
|
|
176
|
+
const to = await client.getBlockNumber();
|
|
177
|
+
const all = [];
|
|
178
|
+
for (let start = network.fromBlock; start <= to; start += LOG_RANGE) {
|
|
179
|
+
all.push(...(await fetch(start, start + LOG_RANGE - 1n < to ? start + LOG_RANGE - 1n : to)));
|
|
180
|
+
}
|
|
181
|
+
return all;
|
|
182
|
+
}
|
|
183
|
+
async function send(request) {
|
|
184
|
+
const hash = await wallet.writeContract(request);
|
|
185
|
+
const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
|
|
186
|
+
if (receipt.status !== "success")
|
|
187
|
+
throw new Error(`Transaction ${hash} reverted`);
|
|
188
|
+
return receipt;
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
account: account.address,
|
|
192
|
+
now: async () => (await client.getBlock()).timestamp,
|
|
193
|
+
ethBalance: () => client.getBalance({ address: account.address }),
|
|
194
|
+
stakeBalance: () => client.readContract({ address: stakeToken, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
|
|
195
|
+
bond: () => client.readContract({ ...proposals, functionName: "bond" }),
|
|
196
|
+
decideWithin: () => client.readContract({ ...proposals, functionName: "decideWithin" }),
|
|
197
|
+
changeThresholdBps: async () => BigInt(await client.readContract({ address: executor, abi: executorReadsAbi, functionName: "changeThresholdBps" })),
|
|
198
|
+
timelock: () => client.readContract({ address: executor, abi: executorAbi, functionName: "timelock" }),
|
|
199
|
+
vetoed: (call) => client.readContract({
|
|
200
|
+
address: executor,
|
|
201
|
+
abi: executorReadsAbi,
|
|
202
|
+
functionName: "vetoedCall",
|
|
203
|
+
args: [keccak256(encodeAbiParameters([{ type: "address" }, { type: "bytes" }], [call.address, call.data]))],
|
|
204
|
+
}),
|
|
205
|
+
async rehearse(call) {
|
|
206
|
+
try {
|
|
207
|
+
await client.call({ account: executor, to: call.address, data: call.data });
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
return error instanceof BaseError ? error.shortMessage : String(error);
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
async propose(call, uri, bond) {
|
|
215
|
+
const allowance = await client.readContract({ address: stakeToken, abi: erc20Abi, functionName: "allowance", args: [account.address, bonds] });
|
|
216
|
+
if (allowance < bond)
|
|
217
|
+
await send({ address: stakeToken, abi: erc20Abi, functionName: "approve", args: [bonds, bond] });
|
|
218
|
+
const receipt = await send({ ...proposals, functionName: "propose", args: [call.address, call.data, uri] });
|
|
219
|
+
const [log] = parseEventLogs({ abi: bondsAbi, eventName: "Proposed", logs: receipt.logs }).filter((l) => l.address.toLowerCase() === bonds.toLowerCase());
|
|
220
|
+
if (!log)
|
|
221
|
+
throw new Error(`The proposal landed in ${receipt.transactionHash} but logged no id`);
|
|
222
|
+
return { id: log.args.id, tx: receipt.transactionHash };
|
|
223
|
+
},
|
|
224
|
+
async get(id) {
|
|
225
|
+
const p = await client.readContract({ ...proposals, functionName: "get", args: [id] });
|
|
226
|
+
return { id, proposer: p.proposer, bond: p.bond, createdAt: p.createdAt, state: p.state };
|
|
227
|
+
},
|
|
228
|
+
async outcome(id) {
|
|
229
|
+
const [settled, expired] = await Promise.all([
|
|
230
|
+
scan((fromBlock, toBlock) => client.getContractEvents({ ...proposals, eventName: "Settled", args: { id }, fromBlock, toBlock })),
|
|
231
|
+
scan((fromBlock, toBlock) => client.getContractEvents({ ...proposals, eventName: "Expired", args: { id }, fromBlock, toBlock })),
|
|
232
|
+
]);
|
|
233
|
+
if (settled[0])
|
|
234
|
+
return { decisionId: settled[0].args.decisionId, adopted: settled[0].args.adopted };
|
|
235
|
+
return expired[0] ? "expired" : undefined;
|
|
236
|
+
},
|
|
237
|
+
async queued(decisionId) {
|
|
238
|
+
const [log] = await scan((fromBlock, toBlock) => client.getContractEvents({ address: executor, abi: executorAbi, eventName: "Queued", args: { decisionId }, fromBlock, toBlock }));
|
|
239
|
+
if (!log)
|
|
240
|
+
return undefined;
|
|
241
|
+
const change = await client.readContract({ address: executor, abi: executorAbi, functionName: "getChange", args: [log.args.changeId] });
|
|
242
|
+
return { changeId: log.args.changeId, eta: change.eta, executed: change.executed, vetoed: change.vetoed };
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const hours = (seconds) => `${Number(seconds) / 3600} hours`;
|
|
247
|
+
const days = (seconds) => `${Number(seconds) / 86_400} days`;
|
|
248
|
+
const percent = (bps) => `${Number(bps) / 100}%`;
|
|
249
|
+
export async function propose(network, chain, call, uri, options) {
|
|
250
|
+
const { log } = options;
|
|
251
|
+
const [bond, balance, gas, within, threshold, timelock, vetoed, revert] = await Promise.all([
|
|
252
|
+
chain.bond(),
|
|
253
|
+
chain.stakeBalance(),
|
|
254
|
+
chain.ethBalance(),
|
|
255
|
+
chain.decideWithin(),
|
|
256
|
+
chain.changeThresholdBps(),
|
|
257
|
+
chain.timelock(),
|
|
258
|
+
chain.vetoed(call),
|
|
259
|
+
chain.rehearse(call),
|
|
260
|
+
]);
|
|
261
|
+
if (vetoed)
|
|
262
|
+
throw new ProposalError(`The guardian vetoed ${call.text} on the ${call.target} before, so the executor never queues it again`);
|
|
263
|
+
if (revert)
|
|
264
|
+
throw new ProposalError(`${call.text} would fail if the executor made it now (${revert}), so the bond would be burned for nothing`);
|
|
265
|
+
log(`Proposal on ${network.name}
|
|
266
|
+
Call ${call.text} on the ${call.target} (${call.address})
|
|
267
|
+
Data ${call.data}
|
|
268
|
+
Bond ${stake(network, bond)}, returned if Jev adopts the change with at least ${percent(threshold)} confidence, burned otherwise
|
|
269
|
+
Decision within ${days(within)}; an adopted change runs ${hours(timelock)} later`);
|
|
270
|
+
if (options.dry) {
|
|
271
|
+
log("Dry run, nothing sent.");
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (balance < bond) {
|
|
275
|
+
const buy = network.buyStake ? ` ${network.buyStake}` : "";
|
|
276
|
+
throw new Error(`The wallet holds ${stake(network, balance)} of the ${stake(network, bond)} bond.${buy}`);
|
|
277
|
+
}
|
|
278
|
+
if (gas === 0n)
|
|
279
|
+
throw new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas}; 0.001 ETH is plenty.`);
|
|
280
|
+
log(`Bonding ${stake(network, bond)} and proposing...`);
|
|
281
|
+
const { id, tx } = await chain.propose(call, uri, bond);
|
|
282
|
+
log(`Proposal ${id} is waiting for Jev (${tx}). Follow it with \`npx brainchain-worker proposal ${id}\`.`);
|
|
283
|
+
}
|
|
284
|
+
export async function proposal(network, chain, id, log) {
|
|
285
|
+
const p = await chain.get(id);
|
|
286
|
+
if (p.state === State.None)
|
|
287
|
+
throw new ProposalError(`There is no proposal ${id} on ${network.name}`);
|
|
288
|
+
const head = `Proposal ${id} by ${p.proposer}, bond ${stake(network, p.bond)}`;
|
|
289
|
+
if (p.state === State.Open) {
|
|
290
|
+
const [within, now] = await Promise.all([chain.decideWithin(), chain.now()]);
|
|
291
|
+
const closes = p.createdAt + within;
|
|
292
|
+
const after = now > closes ? `its window closed at ${when(closes)}, so anyone can return the bond with expire` : `Jev decides by ${when(closes)}`;
|
|
293
|
+
log(`${head}\n Waiting for Jev: ${after}.`);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const outcome = await chain.outcome(id);
|
|
297
|
+
if (outcome === "expired") {
|
|
298
|
+
log(`${head}\n Not decided in its window; the bond was returned.`);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (outcome === undefined) {
|
|
302
|
+
log(`${head}\n ${p.state === State.Returned ? "Settled, bond returned" : "Settled, bond burned"}.`);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (!outcome.adopted) {
|
|
306
|
+
log(`${head}\n Jev kept the current setting (decision ${outcome.decisionId}); the bond was burned.`);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const change = await chain.queued(outcome.decisionId);
|
|
310
|
+
const state = !change
|
|
311
|
+
? "not queued yet"
|
|
312
|
+
: change.executed
|
|
313
|
+
? `change ${change.changeId} has run`
|
|
314
|
+
: change.vetoed
|
|
315
|
+
? `change ${change.changeId} was vetoed by the guardian`
|
|
316
|
+
: `change ${change.changeId} runs from ${when(change.eta)}`;
|
|
317
|
+
log(`${head}\n Jev adopted it (decision ${outcome.decisionId}) and the bond was returned; ${state}.`);
|
|
318
|
+
}
|
package/dist/standing.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { formatUnits } from "viem";
|
|
2
2
|
import { ApplicationState } 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) {
|
|
5
|
+
const [whole, fraction] = formatUnits(amount, decimals).split(".");
|
|
6
|
+
return `${whole.replace(/\B(?=(\d{3})+$)/g, ",")}${fraction === undefined ? "" : `.${fraction}`}`;
|
|
7
|
+
}
|
|
8
|
+
export const stake = (network, amount) => `${grouped(amount, 18)} ${network.stakeSymbol}`;
|
|
4
9
|
export const usdg = (network, amount) => `${formatUnits(amount, 6)} ${network.bountySymbol}`;
|
|
5
10
|
export const when = (seconds) => new Date(Number(seconds) * 1000).toISOString().replace(".000Z", "Z");
|
|
6
11
|
/** The worker's standing with the board and the staking contract, and what join would do about it. */
|
|
@@ -47,7 +52,8 @@ export function hireable({ allowed, w, min, applications, change, pending }, net
|
|
|
47
52
|
}
|
|
48
53
|
/**
|
|
49
54
|
* The wallet's stake token and, below the minimum stake, where join would find
|
|
50
|
-
* what it needs. A stake token without a faucet has no `faucet
|
|
55
|
+
* what it needs. A stake token without a faucet has no `faucet`; one that is
|
|
56
|
+
* bought instead says where.
|
|
51
57
|
*/
|
|
52
58
|
export function wallet(network, s, balance, bond, faucet, now) {
|
|
53
59
|
const has = stake(network, balance);
|
|
@@ -60,7 +66,8 @@ export function wallet(network, s, balance, bond, faucet, now) {
|
|
|
60
66
|
return `${has}; join needs ${stake(network, need)} and claims the rest from the faucet, which gives ${stake(network, faucet.amount)} a day`;
|
|
61
67
|
const reopens = faucet ? `. The faucet opens again for this wallet at ${when(faucet.opensAt)}` : "";
|
|
62
68
|
const unstaking = s.w.unstaking > 0n ? `, and the ${stake(network, s.w.unstaking)} unstaking can be withdrawn ${now >= s.w.unlocksAt ? "now" : `after ${when(s.w.unlocksAt)}`}` : "";
|
|
63
|
-
|
|
69
|
+
const buy = network.buyStake ? `. ${network.buyStake}` : "";
|
|
70
|
+
return `${has}; join needs ${stake(network, need)}${reopens}${unstaking}${buy}`;
|
|
64
71
|
}
|
|
65
72
|
/** What join says when the wallet holds too little of a stake token that has no faucet. */
|
|
66
73
|
export function shortOfStake(network, w, balance, need, now) {
|
package/dist/state.js
CHANGED
|
@@ -126,8 +126,8 @@ export function lock(file) {
|
|
|
126
126
|
}
|
|
127
127
|
/**
|
|
128
128
|
* Moves the state 0.3.0 and earlier kept per address alone to `file`, once,
|
|
129
|
-
* when it was kept for `board
|
|
130
|
-
* until a run there moves it.
|
|
129
|
+
* when it was kept for `board`, and says whether it did. State for the other
|
|
130
|
+
* network stays where it is until a run there moves it.
|
|
131
131
|
*/
|
|
132
132
|
function migrate(old, file, board) {
|
|
133
133
|
let text;
|
|
@@ -136,30 +136,32 @@ function migrate(old, file, board) {
|
|
|
136
136
|
}
|
|
137
137
|
catch (error) {
|
|
138
138
|
if (error.code === "ENOENT")
|
|
139
|
-
return;
|
|
139
|
+
return false;
|
|
140
140
|
throw error;
|
|
141
141
|
}
|
|
142
142
|
if (parse(text)?.board !== board.toLowerCase())
|
|
143
|
-
return;
|
|
143
|
+
return false;
|
|
144
144
|
// A run from before the move may still be working on this network's tasks from the old file.
|
|
145
145
|
const pid = holder(`${old}.lock`);
|
|
146
146
|
if (alive(pid))
|
|
147
147
|
throw held(pid, `${old}.lock`);
|
|
148
148
|
if (existsSync(`${file}.json`))
|
|
149
|
-
return;
|
|
149
|
+
return false;
|
|
150
150
|
// Removed rather than moved: this run already holds the new lock.
|
|
151
151
|
rmSync(`${old}.lock`, { force: true });
|
|
152
152
|
renameSync(`${old}.json`, `${file}.json`);
|
|
153
|
+
return true;
|
|
153
154
|
}
|
|
154
155
|
/**
|
|
155
156
|
* Takes the worker's lock on one network and loads its saved state. Each
|
|
156
157
|
* network has its own `<address>.<network>.json` and `.lock`, so one key can
|
|
157
158
|
* run on testnet and mainnet at once without sharing cursors or tasks.
|
|
158
159
|
*/
|
|
159
|
-
export function openStore(address, network,
|
|
160
|
+
export function openStore(address, network, log = console.log, dir = stateDir()) {
|
|
160
161
|
const old = join(dir, address);
|
|
161
162
|
const file = `${old}.${network.id}`;
|
|
162
163
|
lock(`${file}.lock`);
|
|
163
|
-
migrate(old, file, network.contracts.taskBoard)
|
|
164
|
-
|
|
164
|
+
if (migrate(old, file, network.contracts.taskBoard))
|
|
165
|
+
log(`moved ${old}.json to ${file}.json`);
|
|
166
|
+
return fileStore(`${file}.json`, network.contracts.taskBoard, (message) => log(`warning: ${message}`));
|
|
165
167
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brainchain-worker",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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>",
|