restake 2.2.0 → 3.0.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 +105 -0
- package/dist/index.js +88 -46
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# staker
|
|
2
|
+
|
|
3
|
+
Automated validator management CLI for [Swarm](https://ethswarm.org/) Bee nodes on Gnosis Chain. Runs an infinite loop that performs periodic staking, token transfers, postage batch top-ups, and wealth redistribution across a set of local Bee nodes.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
Each loop iteration:
|
|
8
|
+
|
|
9
|
+
1. Queries all configured Bee nodes (at `http://localhost:1633` through `http://localhost:1632+n`) for wallet balance and redistribution freeze state
|
|
10
|
+
2. Filters to eligible nodes (sufficient BZZ balance, not frozen)
|
|
11
|
+
3. Picks a random eligible node and attempts actions in priority order:
|
|
12
|
+
- **Postage top-up** — fetches TTLs from `https://bzz.limo/batches`; tops up a random configured batch whose TTL is under 1 year. Skipped if all batches are above 1 year.
|
|
13
|
+
- **Stake / aid** — checks every node's current stake. Nodes in the bottom 50% by stake deposit BZZ as stake (skipped if already at or above `--stake-threshold`). Nodes in the top 50% transfer BZZ to a random bottom-50% node to help it stake.
|
|
14
|
+
- **External transfer** — fallback only when both top-up and stake/aid have nothing to do; sends BZZ to the configured external wallet.
|
|
15
|
+
4. The loop coin-flips the order of top-up vs stake/aid on each iteration; the external transfer always runs last.
|
|
16
|
+
5. Reports the result (success or failure) to a Telegram chat
|
|
17
|
+
6. Sleeps for the configured interval and repeats
|
|
18
|
+
|
|
19
|
+
## Prerequisites
|
|
20
|
+
|
|
21
|
+
- Node.js 18+
|
|
22
|
+
- pnpm
|
|
23
|
+
- `n` Bee nodes running locally on consecutive ports starting at 1633
|
|
24
|
+
- A Gnosis Chain RPC endpoint
|
|
25
|
+
- A Telegram bot token and chat ID (for notifications)
|
|
26
|
+
|
|
27
|
+
## Install & build
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pnpm install
|
|
31
|
+
pnpm build
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
node dist/index.js \
|
|
38
|
+
--n <count> \
|
|
39
|
+
--bzz <amount> \
|
|
40
|
+
--sleep <interval> \
|
|
41
|
+
--postage-batch-id <batchId1>,<batchId2> \
|
|
42
|
+
--external-wallet <address> \
|
|
43
|
+
--private-keys-path <path> \
|
|
44
|
+
--json-rpc-url <rpcUrl> \
|
|
45
|
+
--telegram-token <token> \
|
|
46
|
+
--telegram-chat-id <chatId> \
|
|
47
|
+
--stake-threshold <bzz>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
All arguments are required.
|
|
51
|
+
|
|
52
|
+
### Arguments
|
|
53
|
+
|
|
54
|
+
| Argument | Description | Example |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `--n` | Number of Bee nodes to manage | `3` |
|
|
57
|
+
| `--bzz` | BZZ amount per operation | `0.5` |
|
|
58
|
+
| `--sleep` | Interval between loop iterations | `5m`, `30s` |
|
|
59
|
+
| `--postage-batch-id` | Comma-separated postage batch IDs to top up | `abc...,def...` |
|
|
60
|
+
| `--external-wallet` | Ethereum address for fund transfers | `0x123...` |
|
|
61
|
+
| `--private-keys-path` | Path to file with private keys (one per line) | `/etc/staker/keys.txt` |
|
|
62
|
+
| `--json-rpc-url` | Gnosis Chain JSON-RPC endpoint | `https://rpc.gnosischain.com` |
|
|
63
|
+
| `--telegram-token` | Telegram Bot API token | `123456:ABC...` |
|
|
64
|
+
| `--telegram-chat-id` | Telegram chat ID for notifications | `123456789` |
|
|
65
|
+
| `--stake-threshold` | Max BZZ stake before a node stops staking | `250` |
|
|
66
|
+
|
|
67
|
+
### Private keys file
|
|
68
|
+
|
|
69
|
+
Plain text, one private key per line, with or without `0x` prefix. Must contain exactly `n` keys. On startup the tool validates each key against the corresponding node's Ethereum address and exits if any mismatch is detected.
|
|
70
|
+
|
|
71
|
+
## Project structure
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
src/index.ts — all application logic (single file)
|
|
75
|
+
dist/index.js — compiled output (generated by build)
|
|
76
|
+
package.json — dependencies and build script
|
|
77
|
+
tsconfig.json — TypeScript config (ES2022, strict)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Key constants (hardcoded in src/index.ts)
|
|
81
|
+
|
|
82
|
+
| Name | Value | Purpose |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `BASE_PORT` | `1633` | Starting port for Bee node discovery |
|
|
85
|
+
| `BZZ_ADDRESS` | `0xdbf3ea6f5bee45c02255b2c26a16f300502f68da` | BZZ token contract on Gnosis Chain |
|
|
86
|
+
| `ONE_YEAR_SECONDS` | `31,557,600` | TTL threshold below which a postage batch is eligible for top-up |
|
|
87
|
+
|
|
88
|
+
## Dependencies
|
|
89
|
+
|
|
90
|
+
- [`@ethersphere/bee-js`](https://github.com/ethersphere/bee-js) — Bee node HTTP client (wallet, stake, postage batch APIs)
|
|
91
|
+
- [`viem`](https://viem.sh/) — Ethereum client for signing and broadcasting transactions on Gnosis Chain
|
|
92
|
+
- [`cafe-utility`](https://github.com/Cafe137/cafe-utility) — CLI argument parsing, scheduling, and utilities
|
|
93
|
+
|
|
94
|
+
## For agents
|
|
95
|
+
|
|
96
|
+
- **Entry point**: `src/index.ts` — the entire application is one file; start there for any changes
|
|
97
|
+
- **Build command**: `pnpm build` (runs `tsc`)
|
|
98
|
+
- **No tests exist** — verify changes by reading the logic and checking TypeScript compilation
|
|
99
|
+
- **No environment variables** — all configuration is via CLI arguments
|
|
100
|
+
- **Blockchain network**: Gnosis Chain (chain ID 100); do not change the target network without updating `BZZ_ADDRESS` and the viem chain config
|
|
101
|
+
- **Action order** is randomised per iteration: a coin flip picks either topup-first or stake/aid-first; the external transfer is always the last resort. `runAction()` returns `false` when nothing was done, triggering the fallback.
|
|
102
|
+
- **Stake/aid logic** lives in `tryStake()`: bottom-50% nodes stake (if under threshold), top-50% nodes transfer BZZ to a random bottom-50% node
|
|
103
|
+
- **Top-up eligibility** is determined by `fetchEligibleBatchIds()`, which filters configured batch IDs against `https://bzz.limo/batches` for TTL < 1 year
|
|
104
|
+
- **Telegram reporting** wraps every action via `runAction()`; if adding a new action, follow the same pattern
|
|
105
|
+
- **Node indexing**: nodes are indexed 0 to n-1; port for node `i` is `BASE_PORT + i`; private key index matches node index
|
package/dist/index.js
CHANGED
|
@@ -25,19 +25,27 @@ const abi = [
|
|
|
25
25
|
const n = cafe_utility_1.Arrays.requireNumberArgument(process_1.argv, 'n');
|
|
26
26
|
const bzz = bee_js_1.BZZ.fromFloat(cafe_utility_1.Arrays.requireNumberArgument(process_1.argv, 'bzz'));
|
|
27
27
|
const sleep = cafe_utility_1.Dates.make(cafe_utility_1.Arrays.requireStringArgument(process_1.argv, 'sleep'));
|
|
28
|
-
const
|
|
28
|
+
const postageBatchIds = cafe_utility_1.Arrays.requireStringArgument(process_1.argv, 'postage-batch-id')
|
|
29
|
+
.split(',')
|
|
30
|
+
.map(s => s.trim())
|
|
31
|
+
.filter(Boolean);
|
|
29
32
|
const externalWallet = cafe_utility_1.Arrays.requireStringArgument(process_1.argv, 'external-wallet');
|
|
30
33
|
const privateKeysPath = cafe_utility_1.Arrays.requireStringArgument(process_1.argv, 'private-keys-path');
|
|
31
34
|
const jsonRpcUrl = cafe_utility_1.Arrays.requireStringArgument(process_1.argv, 'json-rpc-url');
|
|
32
35
|
const telegramToken = cafe_utility_1.Arrays.requireStringArgument(process_1.argv, 'telegram-token');
|
|
33
36
|
const telegramChatId = cafe_utility_1.Arrays.requireStringArgument(process_1.argv, 'telegram-chat-id');
|
|
37
|
+
const stakeThreshold = bee_js_1.BZZ.fromFloat(cafe_utility_1.Arrays.requireNumberArgument(process_1.argv, 'stake-threshold'));
|
|
34
38
|
const BASE_PORT = 1633;
|
|
39
|
+
const ONE_YEAR_SECONDS = 365.25 * 24 * 3600;
|
|
35
40
|
const privateKeys = (0, fs_1.readFileSync)(privateKeysPath, 'utf-8').split('\n').filter(Boolean);
|
|
36
41
|
if (n !== privateKeys.length) {
|
|
37
42
|
console.error(`Expected ${n} private keys, but got ${privateKeys.length}`);
|
|
38
43
|
(0, process_1.exit)(1);
|
|
39
44
|
}
|
|
40
|
-
main()
|
|
45
|
+
main().catch(error => {
|
|
46
|
+
console.error(error);
|
|
47
|
+
(0, process_1.exit)(1);
|
|
48
|
+
});
|
|
41
49
|
async function main() {
|
|
42
50
|
await validatePrivateKeys();
|
|
43
51
|
runLoop();
|
|
@@ -64,17 +72,19 @@ function runLoop() {
|
|
|
64
72
|
}
|
|
65
73
|
const port = cafe_utility_1.Arrays.pick(eligiblePorts);
|
|
66
74
|
const bee = new bee_js_1.Bee(`http://localhost:${port}`);
|
|
67
|
-
if (cafe_utility_1.Random.chance(1 /
|
|
68
|
-
await runAction(port,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
else if (cafe_utility_1.Random.chance(1 / 2)) {
|
|
74
|
-
await runAction(port, `top up postage batch ${postageBatchId}`, () => topup(bee, port));
|
|
75
|
+
if (cafe_utility_1.Random.chance(1 / 2)) {
|
|
76
|
+
if (!(await runAction(port, 'top up postage batch', () => tryTopup(bee, port)))) {
|
|
77
|
+
if (!(await runAction(port, 'stake/aid', () => tryStake(bee, port)))) {
|
|
78
|
+
await runAction(port, `transfer ${bzz.toDecimalString()} BZZ to external wallet`, () => doTransfer(port));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
75
81
|
}
|
|
76
82
|
else {
|
|
77
|
-
await runAction(port,
|
|
83
|
+
if (!(await runAction(port, 'stake/aid', () => tryStake(bee, port)))) {
|
|
84
|
+
if (!(await runAction(port, 'top up postage batch', () => tryTopup(bee, port)))) {
|
|
85
|
+
await runAction(port, `transfer ${bzz.toDecimalString()} BZZ to external wallet`, () => doTransfer(port));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
78
88
|
}
|
|
79
89
|
}, sleep, console.error);
|
|
80
90
|
}
|
|
@@ -92,56 +102,82 @@ async function findEligiblePorts() {
|
|
|
92
102
|
}
|
|
93
103
|
async function runAction(port, description, action) {
|
|
94
104
|
try {
|
|
95
|
-
await action();
|
|
96
|
-
|
|
105
|
+
const done = await action();
|
|
106
|
+
if (done) {
|
|
107
|
+
await sendTelegramMessage(`Port ${port}: ${description} succeeded`);
|
|
108
|
+
}
|
|
109
|
+
return done;
|
|
97
110
|
}
|
|
98
111
|
catch (error) {
|
|
99
112
|
await sendTelegramMessage(`Port ${port}: ${description} failed: ${error}`);
|
|
113
|
+
throw error;
|
|
100
114
|
}
|
|
101
115
|
}
|
|
102
|
-
async function
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
async function topup(bee, port) {
|
|
116
|
+
async function tryTopup(bee, port) {
|
|
117
|
+
const eligibleBatchIds = await fetchEligibleBatchIds();
|
|
118
|
+
if (eligibleBatchIds.length === 0) {
|
|
119
|
+
console.log(`:${port} no postage batches need topping up`);
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
const batchId = cafe_utility_1.Arrays.pick(eligibleBatchIds);
|
|
111
123
|
const postageBatches = await bee.getGlobalPostageBatches();
|
|
112
|
-
const postageBatch = postageBatches.find(batch => batch.batchID.toString() ===
|
|
124
|
+
const postageBatch = postageBatches.find(batch => batch.batchID.toString() === batchId);
|
|
113
125
|
if (!postageBatch) {
|
|
114
|
-
console.error(
|
|
115
|
-
return;
|
|
126
|
+
console.error(`:${port} postage batch ${batchId} not found in global batches`);
|
|
127
|
+
return false;
|
|
116
128
|
}
|
|
117
129
|
const { amount } = await bee.calculateTopUpForBzz(postageBatch.depth, bzz);
|
|
118
|
-
console.log(`:${port} topping up postage batch ${
|
|
130
|
+
console.log(`:${port} topping up postage batch ${batchId} with ${amount} amount`);
|
|
119
131
|
await bee.topUpBatch(postageBatch.batchID, amount);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
async function tryStake(bee, port) {
|
|
135
|
+
const stakes = await getNodeStakes();
|
|
136
|
+
const bottomHalfPorts = getBottomHalfPorts(stakes);
|
|
137
|
+
if (bottomHalfPorts.has(port)) {
|
|
138
|
+
const currentStake = stakes.get(port);
|
|
139
|
+
if (currentStake.gte(stakeThreshold)) {
|
|
140
|
+
console.log(`:${port} in bottom 50% but stake above threshold, skipping`);
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
console.log(`:${port} depositing ${bzz.toDecimalString()} BZZ as stake`);
|
|
144
|
+
await bee.depositStake(bzz);
|
|
145
|
+
return true;
|
|
127
146
|
}
|
|
128
147
|
else {
|
|
129
|
-
|
|
148
|
+
const bottomPorts = [...bottomHalfPorts];
|
|
149
|
+
const targetPort = cafe_utility_1.Arrays.pick(bottomPorts);
|
|
150
|
+
const targetAddress = (0, accounts_1.privateKeyToAccount)(ensure0x(privateKeys[targetPort - BASE_PORT])).address;
|
|
151
|
+
console.log(`:${port} aiding port ${targetPort} (${targetAddress}) with ${bzz.toDecimalString()} BZZ`);
|
|
152
|
+
await transferBZZ(privateKeys[port - BASE_PORT], targetAddress, bzz);
|
|
153
|
+
return true;
|
|
130
154
|
}
|
|
131
155
|
}
|
|
132
|
-
async function
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
156
|
+
async function doTransfer(port) {
|
|
157
|
+
console.log(`:${port} transferring ${bzz.toDecimalString()} BZZ to external wallet`);
|
|
158
|
+
await transferBZZ(privateKeys[port - BASE_PORT], externalWallet, bzz);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
async function fetchEligibleBatchIds() {
|
|
162
|
+
const response = await fetch('https://bzz.limo/batches');
|
|
163
|
+
if (!response.ok) {
|
|
164
|
+
throw new Error(`Failed to fetch batches: HTTP ${response.status}`);
|
|
165
|
+
}
|
|
166
|
+
const batches = (await response.json());
|
|
167
|
+
return batches.filter(b => postageBatchIds.includes(b.batchID) && b.batchTTL < ONE_YEAR_SECONDS).map(b => b.batchID);
|
|
168
|
+
}
|
|
169
|
+
async function getNodeStakes() {
|
|
170
|
+
const entries = await Promise.all(Array.from({ length: n }, (_, i) => {
|
|
136
171
|
const port = BASE_PORT + i;
|
|
137
172
|
const bee = new bee_js_1.Bee(`http://localhost:${port}`);
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
173
|
+
return bee.getStake().then(stake => [port, stake]);
|
|
174
|
+
}));
|
|
175
|
+
return new Map(entries);
|
|
176
|
+
}
|
|
177
|
+
function getBottomHalfPorts(stakes) {
|
|
178
|
+
const sorted = [...stakes.entries()].sort((a, b) => (a[1].lt(b[1]) ? -1 : a[1].gt(b[1]) ? 1 : 0));
|
|
179
|
+
const bottomCount = Math.ceil(n / 2);
|
|
180
|
+
return new Set(sorted.slice(0, bottomCount).map(([port]) => port));
|
|
145
181
|
}
|
|
146
182
|
async function transferBZZ(privateKey, targetAddress, amount) {
|
|
147
183
|
const account = (0, accounts_1.privateKeyToAccount)(ensure0x(privateKey));
|
|
@@ -172,7 +208,7 @@ async function getTransactionCount(address) {
|
|
|
172
208
|
jsonrpc: '2.0',
|
|
173
209
|
id: 1,
|
|
174
210
|
method: 'eth_getTransactionCount',
|
|
175
|
-
params: [address.toLowerCase(), '
|
|
211
|
+
params: [address.toLowerCase(), 'pending']
|
|
176
212
|
};
|
|
177
213
|
const count = await fetchJsonRpcHexString(payload);
|
|
178
214
|
return parseInt(count, 16);
|
|
@@ -183,8 +219,14 @@ async function fetchJsonRpcHexString(payload) {
|
|
|
183
219
|
headers: { 'Content-Type': 'application/json' },
|
|
184
220
|
body: JSON.stringify(payload)
|
|
185
221
|
});
|
|
222
|
+
if (!response.ok) {
|
|
223
|
+
throw new Error(`JSON-RPC request failed: HTTP ${response.status}`);
|
|
224
|
+
}
|
|
186
225
|
const data = await response.json();
|
|
187
226
|
const object = cafe_utility_1.Types.asObject(data);
|
|
227
|
+
if (object.error) {
|
|
228
|
+
throw new Error(`JSON-RPC error: ${JSON.stringify(object.error)}`);
|
|
229
|
+
}
|
|
188
230
|
return cafe_utility_1.Types.asHexString(object.result, { strictPrefix: true, uneven: true });
|
|
189
231
|
}
|
|
190
232
|
function ensure0x(value) {
|