restake 2.1.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.
Files changed (3) hide show
  1. package/README.md +105 -0
  2. package/dist/index.js +105 -51
  3. 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 postageBatchId = cafe_utility_1.Arrays.requireStringArgument(process_1.argv, 'postage-batch-id');
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();
@@ -57,79 +65,119 @@ async function validatePrivateKeys() {
57
65
  }
58
66
  function runLoop() {
59
67
  cafe_utility_1.System.forever(async () => {
60
- const port = BASE_PORT + cafe_utility_1.Random.intBetween(0, n - 1);
61
- const bee = new bee_js_1.Bee(`http://localhost:${port}`);
62
- const wallet = await bee.getWalletBalance();
63
- if (wallet.bzzBalance.lt(bzz)) {
64
- console.log(`:${port} balance is ${wallet.bzzBalance.toDecimalString()} BZZ, skipping`);
68
+ const eligiblePorts = await findEligiblePorts();
69
+ if (eligiblePorts.length === 0) {
70
+ console.log('no eligible nodes found, skipping');
65
71
  return;
66
72
  }
67
- if (cafe_utility_1.Random.chance(1 / 4)) {
68
- await runAction(port, `stake ${bzz.toDecimalString()} BZZ`, () => stake(bee, port));
69
- }
70
- else if (cafe_utility_1.Random.chance(1 / 3)) {
71
- await runAction(port, `transfer ${bzz.toDecimalString()} BZZ to external wallet`, () => transfer(port));
72
- }
73
- else if (cafe_utility_1.Random.chance(1 / 2)) {
74
- await runAction(port, `top up postage batch ${postageBatchId}`, () => topup(bee, port));
73
+ const port = cafe_utility_1.Arrays.pick(eligiblePorts);
74
+ const bee = new bee_js_1.Bee(`http://localhost:${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, `aid weakest node with ${bzz.toDecimalString()} BZZ`, () => aid(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
  }
91
+ async function findEligiblePorts() {
92
+ const eligible = [];
93
+ for (let i = 0; i < n; i++) {
94
+ const port = BASE_PORT + i;
95
+ const bee = new bee_js_1.Bee(`http://localhost:${port}`);
96
+ const [wallet, redistribution] = await Promise.all([bee.getWalletBalance(), bee.getRedistributionState()]);
97
+ if (!redistribution.isFrozen && wallet.bzzBalance.gte(bzz)) {
98
+ eligible.push(port);
99
+ }
100
+ }
101
+ return eligible;
102
+ }
81
103
  async function runAction(port, description, action) {
82
104
  try {
83
- await action();
84
- await sendTelegramMessage(`Port ${port}: ${description} succeeded`);
105
+ const done = await action();
106
+ if (done) {
107
+ await sendTelegramMessage(`Port ${port}: ${description} succeeded`);
108
+ }
109
+ return done;
85
110
  }
86
111
  catch (error) {
87
112
  await sendTelegramMessage(`Port ${port}: ${description} failed: ${error}`);
113
+ throw error;
88
114
  }
89
115
  }
90
- async function stake(bee, port) {
91
- console.log(`:${port} depositing ${bzz.toDecimalString()} BZZ as stake`);
92
- await bee.depositStake(bzz);
93
- }
94
- async function transfer(port) {
95
- console.log(`:${port} transferring ${bzz.toDecimalString()} BZZ to external wallet`);
96
- await transferBZZ(privateKeys[port - BASE_PORT], externalWallet, bzz);
97
- }
98
- 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);
99
123
  const postageBatches = await bee.getGlobalPostageBatches();
100
- const postageBatch = postageBatches.find(batch => batch.batchID.toString() === postageBatchId);
124
+ const postageBatch = postageBatches.find(batch => batch.batchID.toString() === batchId);
101
125
  if (!postageBatch) {
102
- console.error(`Postage batch with ID ${postageBatchId} not found`);
103
- return;
126
+ console.error(`:${port} postage batch ${batchId} not found in global batches`);
127
+ return false;
104
128
  }
105
129
  const { amount } = await bee.calculateTopUpForBzz(postageBatch.depth, bzz);
106
- console.log(`:${port} topping up postage batch ${postageBatchId} with ${amount} amount`);
130
+ console.log(`:${port} topping up postage batch ${batchId} with ${amount} amount`);
107
131
  await bee.topUpBatch(postageBatch.batchID, amount);
108
- }
109
- async function aid(port) {
110
- const weakestAddress = await findWeakestNodeAddress();
111
- const currentAddress = (0, accounts_1.privateKeyToAccount)(ensure0x(privateKeys[port - BASE_PORT])).address;
112
- if (weakestAddress && weakestAddress.toLowerCase() !== currentAddress.toLowerCase()) {
113
- console.log(`:${port} aiding weakest node ${weakestAddress} with ${bzz.toDecimalString()} BZZ`);
114
- await transferBZZ(privateKeys[port - BASE_PORT], weakestAddress, bzz);
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;
115
146
  }
116
147
  else {
117
- console.log(`:${port} weakest node is self, skipping aid`);
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;
118
154
  }
119
155
  }
120
- async function findWeakestNodeAddress() {
121
- let minStake = null;
122
- let weakestAddress = null;
123
- for (let i = 0; i < n; i++) {
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) => {
124
171
  const port = BASE_PORT + i;
125
172
  const bee = new bee_js_1.Bee(`http://localhost:${port}`);
126
- const stake = await bee.getStake();
127
- if (minStake === null || stake.lt(minStake)) {
128
- minStake = stake;
129
- weakestAddress = (0, accounts_1.privateKeyToAccount)(ensure0x(privateKeys[i])).address;
130
- }
131
- }
132
- return weakestAddress;
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));
133
181
  }
134
182
  async function transferBZZ(privateKey, targetAddress, amount) {
135
183
  const account = (0, accounts_1.privateKeyToAccount)(ensure0x(privateKey));
@@ -160,7 +208,7 @@ async function getTransactionCount(address) {
160
208
  jsonrpc: '2.0',
161
209
  id: 1,
162
210
  method: 'eth_getTransactionCount',
163
- params: [address.toLowerCase(), 'latest']
211
+ params: [address.toLowerCase(), 'pending']
164
212
  };
165
213
  const count = await fetchJsonRpcHexString(payload);
166
214
  return parseInt(count, 16);
@@ -171,8 +219,14 @@ async function fetchJsonRpcHexString(payload) {
171
219
  headers: { 'Content-Type': 'application/json' },
172
220
  body: JSON.stringify(payload)
173
221
  });
222
+ if (!response.ok) {
223
+ throw new Error(`JSON-RPC request failed: HTTP ${response.status}`);
224
+ }
174
225
  const data = await response.json();
175
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
+ }
176
230
  return cafe_utility_1.Types.asHexString(object.result, { strictPrefix: true, uneven: true });
177
231
  }
178
232
  function ensure0x(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "restake",
3
- "version": "2.1.0",
3
+ "version": "3.0.0",
4
4
  "description": "",
5
5
  "bin": {
6
6
  "restake": "./dist/index.js"