restake
v5.4.0
Published
Automated validator management CLI for [Swarm](https://ethswarm.org/) Bee nodes on Gnosis Chain. Performs staking, token transfers, postage batch top-ups, and wealth redistribution across a set of local Bee nodes.
Readme
staker
Automated validator management CLI for Swarm Bee nodes on Gnosis Chain. Performs staking, token transfers, postage batch top-ups, and wealth redistribution across a set of local Bee nodes.
One invocation is one tick — the process performs at most a single action and exits. Scheduling is external; run it from a cronjob.
What it does
Each invocation:
- Queries all configured Bee nodes (at
http://localhost:1633throughhttp://localhost:1632+n) for wallet balances (BZZ and xDAI) and redistribution freeze state - Filters to eligible nodes (BZZ balance at or above
--bzz, not frozen). If none are eligible, it reports the fleet's total BZZ and its highest single-node balance to Telegram — so a quiet tick still says how far the fleet is from the--bzzthreshold — and exits without acting. - Picks a random eligible node. If that node holds less than
0.1xDAI, it swaps--bzzBZZ for xDAI on SushiSwap and the tick ends — a node without xDAI cannot pay gas for anything else. Otherwise it picks one of four actions with equal probability:- Postage top-up — tops up a random configured batch, unconditionally.
- Stake — deposits BZZ as stake on the picked node.
- Aid — transfers BZZ to a random other managed node, so it can stake later.
- External transfer — sends BZZ to the configured external wallet.
- If the chosen action turns out to have nothing to do, the invocation ends without a fallback; the next tick rolls again.
- Reports the result (success or failure) to a Telegram chat, then exits
Self-sustaining
Nodes spend xDAI on gas but earn BZZ, so left alone they eventually run dry and stop being able to transact. The gas check above closes that loop: whenever a picked node is below MIN_XDAI_BALANCE (0.1 xDAI), it always converts --bzz BZZ into native xDAI instead of doing a random action.
The swap goes through the SushiSwap V3 BZZ/WXDAI pool on the 0.3% fee tier — SushiSwap has no V2 pair for BZZ on Gnosis. It is two transactions, the first confirmed before the second is sent:
approvethe SushiSwap router for--bzzBZZ, skipped when the allowance already suffices- A single
multicallofexactInputSingle(BZZ → WXDAI, output kept by the router) followed byunwrapWETH9(WXDAI → native xDAI, forwarded to the node's own wallet), so the node ends up with the native token it pays gas in
amountOutMinimum is quoted from the pool's slot0 spot price less the pool fee and SWAP_SLIPPAGE_PERCENT, so no quoter contract is needed. Quoting off spot works because a swap of this size barely moves the price: measured against live pool state, --bzz 10 comes out 0.58% under raw spot (the 0.3% fee plus ~0.28% impact), leaving ~4.4pp of headroom under the 5% guard. The margin only gets tight around 100 BZZ per swap — raise SWAP_SLIPPAGE_PERCENT before going that big, or check the pool's liquidity first.
At --bzz 10 a swap yields roughly 0.4 xDAI, about 4× MIN_XDAI_BALANCE, so one swap lifts a node clear of the threshold and it does not re-trigger on the next tick. With a much smaller --bzz the node would instead swap on several consecutive ticks until it built up a buffer.
Prerequisites
- Node.js 18+
- pnpm
nBee nodes running locally on consecutive ports starting at 1633- A Gnosis Chain RPC endpoint
- A Telegram bot token and chat ID (for notifications)
Install & build
pnpm install
pnpm buildUsage
node dist/index.js \
--n <count> \
--bzz <amount> \
--postage-batch-id <batchId1>,<batchId2> \
--external-wallet <address> \
--private-keys-path <path> \
--json-rpc-url <rpcUrl> \
--telegram-token <token> \
--telegram-chat-id <chatId>All arguments are required.
Arguments
| Argument | Description | Example |
|---|---|---|
| --n | Number of Bee nodes to manage | 3 |
| --bzz | BZZ amount per operation | 0.5 |
| --postage-batch-id | Comma-separated postage batch IDs to top up | abc...,def... |
| --external-wallet | Ethereum address for fund transfers | 0x123... |
| --private-keys-path | Path to file with private keys (one per line) | /etc/staker/keys.txt |
| --json-rpc-url | Gnosis Chain JSON-RPC endpoint | https://rpc.gnosischain.com |
| --telegram-token | Telegram Bot API token | 123456:ABC... |
| --telegram-chat-id | Telegram chat ID for notifications | 123456789 |
Cron
The tool does not schedule itself; the invocation interval is whatever the cronjob uses. For example, every 5 minutes:
*/5 * * * * /usr/bin/node /opt/staker/dist/index.js --n 3 --bzz 0.5 ... >> /var/log/staker.log 2>&1The process exits 0 after a completed (or intentionally skipped) tick, and 1 on a validation failure or a failed action.
Private keys file
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.
Project structure
src/index.ts — all application logic (single file)
dist/index.js — compiled output (generated by build)
package.json — dependencies and build script
tsconfig.json — TypeScript config (ES2022, strict)Key constants (hardcoded in src/index.ts)
| Name | Value | Purpose |
|---|---|---|
| BASE_PORT | 1633 | Starting port for Bee node discovery |
| BZZ_ADDRESS | 0xdbf3ea6f5bee45c02255b2c26a16f300502f68da | BZZ token contract on Gnosis Chain |
| WXDAI_ADDRESS | 0xe91d153e0b41518a2ce8dd3d7944fa863463a97d | WXDAI token contract; the router's WETH9 |
| SUSHI_V3_FACTORY | 0xf78031cbca409f2fb6876bdfdbc1b2df24cf9bef | SushiSwap V3 factory, used to look up the BZZ/WXDAI pool |
| SUSHI_V3_ROUTER | 0x4f54dd2f4f30347d841b7783ad08c050d8410a9d | SushiSwap V3 SwapRouter (Uniswap V3 periphery) |
| SUSHI_POOL_FEE | 3000 | Fee tier of the BZZ/WXDAI pool, in hundredths of a bip (0.3%) |
| SWAP_SLIPPAGE_PERCENT | 5 | How far below the spot quote amountOutMinimum is set |
| MIN_XDAI_BALANCE | 0.1 xDAI | Below this a picked node swaps BZZ for xDAI instead of acting |
| PLUR_DIGITS | 16 | Decimal scale of BZZ amounts (1 BZZ = 10^16 PLUR) |
| XDAI_DIGITS | 18 | Decimal scale of xDAI/WXDAI amounts |
Dependencies
axios— HTTP client for every outgoing request (Bee nodes, JSON-RPC, Telegram)viem— Ethereum client for signing and broadcasting transactions on Gnosis Chaincafe-utility— CLI argument parsing, response validation (Types), andFixedPointNumberfor BZZ amounts
There is no @ethersphere/bee-js dependency: the Bee HTTP API is called directly (see below), and BZZ amounts are plain FixedPointNumbers.
For agents
- Entry point:
src/index.ts— the entire application is one file; start there for any changes - Build command:
pnpm build(runstsc) - No tests exist — verify changes by reading the logic and checking TypeScript compilation
- No environment variables — all configuration is via CLI arguments
- Blockchain network: Gnosis Chain (chain ID 100); do not change the target network without updating
BZZ_ADDRESSand the viem chain config - One process = one tick:
main()validates keys, callsrunTick()once, and returns. There is no loop and no scheduling — cron owns the interval. Do not reintroduceSystem.forever()or a--sleepargument. - Action selection is uniformly random per tick over
ACTIONS(topup,stake,aid,transfer); there is no priority order and no fallback. An action returningfalse(e.g.tryAid()whenn === 1) simply means the tick did nothing. - The swap is the one exception to random selection:
runTick()checks the picked node's xDAI balance againstMIN_XDAI_BALANCEfirst and, if it is short, runsdoSwap()and returns without rolling for an action. Keep it that way — it is what makes nodes self-sustaining. Do not add it toACTIONS. - Swap encoding: the router is Uniswap V3 periphery
SwapRouter, notSwapRouter02—exactInputSingletakes adeadlineinside its params struct and onlymulticall(bytes[])exists (nomulticall(uint256, bytes[])).exactInputSinglehas noaddress(0)recipient sentinel either, so the intermediate recipient is spelled out asSUSHI_V3_ROUTERforunwrapWETH9to pick up. unwrapWETH9forwards the router's whole WXDAI balance to the recipient and reverts (STE) if the recipient cannot accept native xDAI. That is fine for node wallets, which are EOAs.- Gas limits are explicit on every transaction, which also stops viem from calling
eth_estimateGas. Measured against live Gnosis state:approveuses ~52k (limit 100k) and the swapmulticall~171k (limit 400k). - Contract reads (
allowance,getPool,slot0,token0) go throughethCall(), which reusesfetchJsonRpcHexString(); encoding and decoding are viem'sencodeFunctionData/decodeFunctionResult. The module-levelpublicClientexists only to await receipts — do not route ordinary reads through it. - Never set
nonceby hand. The Bee node signs with the same key as this tool (redistribution claims, cheque cashouts, its own stake and batch transactions), so the nonce sequence is shared and not predictable from here. Omittingnoncemakes viem read the pending count immediately before signing — the latest possible moment — and a Bee transaction that is already mined or queued just moves us to the next slot. Precomputingnonce + 1for a follow-up transaction is specifically wrong: the gap between the two sends is long enough for Bee to claim that slot, and the tool would then replace Bee's transaction or be replaced by it. - Every transaction sent by viem is confirmed with
confirmTransaction()before it is reported as a success, so "succeeded" means mined, not merely accepted by the RPC. It checks three things in order: thatreceipt.transactionHashstill matches the hash sent (viem resolves with the replacement's receipt when something else takes our nonce, rather than throwing, so without this check a Bee transaction winning the slot would read as our success), thatreceipt.statusis notreverted, and otherwise logs the block.findRevertReason()then replays a reverted call to recover its reason, best effort. Transactions the Bee API sends (stake, top-up) need none of this — Bee waits for them itself. - Stake is
doStake()— an unconditional deposit; aid istryAid()— a BZZ transfer to a random other node. Neither consults current stake levels. - ERC-20 calls use viem's exported
erc20Abi(transfer,approve,allowance); the previously hand-rolledtransferABI is gone. Becauseerc20Abiis strongly typed, addresses must be0x-prefixed — pass CLI-supplied addresses throughensure0x(). - Top-up is unconditional —
tryTopup()picks a random ID from--postage-batch-idand tops it up. There is no TTL guard; thehttps://bzz.limo/batcheslookup that used to filter for TTL < 1 year is gone. Do not reintroduce it. The only remote read left isgetGlobalPostageBatches(), needed for the batch depth in the amount formula. - Telegram reporting wraps every action via
runAction(); if adding a new action, follow the same pattern. Failures go throughdescribeError(), which prefers viem'sshortMessage— stringifying a viem error yields a ~28 line, up to ~3000 character dump that buries the reason, whileshortMessagekeeps the revert reason (Execution reverted with reason: Too little received.) on one line.sendTelegramMessage()also caps text atTELEGRAM_MAX_LENGTH, because Telegram answers an overlong message with an HTTP 400 that is only logged, so the report would be lost. The full error still reaches the log, sincerunAction()rethrows. - Idle ticks report too —
reportNoEligibleNodes()sends the fleet's total BZZ and its highest single-node balance (with that node's port, marked when frozen) whenever nothing is eligible, so silence on Telegram means the tick did not run rather than that it found nothing. Frozen nodes count towards both figures: a freeze lifts on its own, so the balance is the number worth watching.scanNodes()returns every node's balances and freeze state andrunTick()filters it, which is what makes those figures available without a second round of Bee calls. - Node indexing: nodes are indexed 0 to n-1; port for node
iisBASE_PORT + i; private key index matches node index - Never use
fetch— Node'sfetchrefuses to connect to ports on the WHATWG "bad port" blocklist (1719, 1720, 2049, 6000, …), which nodeBASE_PORT + ican land on. All requests go throughaxios, whose Node adapter has no such restriction. Bee endpoints are called throughbeeRequest(); do not reintroducebee-js(orfetch) for them. - BZZ amounts are
FixedPointNumbers at scalePLUR_DIGITS;.valueis the amount in PLUR (what the Bee API and the token contract take),.toDecimalString()is the human-readable BZZ form, and comparisons go through.compare(). This is exactly whatbee-js'sBZZclass wrapped. xDAI amounts are the same type atXDAI_DIGITS; do not compare the two scales against each other. getWallet()returns both balances from oneGET /walletcall (bzzBalanceandnativeTokenBalance), so no extra RPC round trip is needed to check gas.
