From a4a5314f79e65bd33a75f41dfd2bd737303cd169 Mon Sep 17 00:00:00 2001 From: Kurdistan Tech Ministry Date: Sat, 21 Feb 2026 17:03:13 +0300 Subject: [PATCH] Add auto-payout bot for AH staking rewards - payout-bot/bot.js: Periodically calls payoutStakersByPage for all validators in completed eras. Runs every 10 minutes. - payout-bot/Dockerfile: Node 20 alpine with @pezkuwi/api - docker-compose.prod.yml: Add payout-bot service with secret mnemonic --- docker-compose.prod.yml | 16 +++++ payout-bot/Dockerfile | 9 +++ payout-bot/bot.js | 128 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 payout-bot/Dockerfile create mode 100644 payout-bot/bot.js diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index fe01c22..a79ad66 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -159,9 +159,25 @@ services: PEOPLE_RPC: ws://217.77.6.126:41944 SCAN_INTERVAL_MS: "300000" + payout-bot: + container_name: payout-pezkuwi + build: + context: ./payout-bot + dockerfile: Dockerfile + restart: unless-stopped + secrets: + - payout_mnemonic + environment: + TZ: UTC + ASSET_HUB_RPC: wss://asset-hub-rpc.pezkuwichain.io + MNEMONIC_FILE: /run/secrets/payout_mnemonic + INTERVAL_MS: "600000" + secrets: noter_mnemonic: file: ./secrets/noter_mnemonic.txt + payout_mnemonic: + file: ./secrets/payout_mnemonic.txt volumes: pgdata: diff --git a/payout-bot/Dockerfile b/payout-bot/Dockerfile new file mode 100644 index 0000000..70680d6 --- /dev/null +++ b/payout-bot/Dockerfile @@ -0,0 +1,9 @@ +FROM node:20-alpine +WORKDIR /app +RUN npm install \ + @pezkuwi/api@^16.5.36 \ + @pezkuwi/keyring@^14.0.25 \ + @pezkuwi/util@^14.0.25 \ + @pezkuwi/util-crypto@^14.0.25 +COPY bot.js ./ +CMD ["node", "bot.js"] diff --git a/payout-bot/bot.js b/payout-bot/bot.js new file mode 100644 index 0000000..d13ddde --- /dev/null +++ b/payout-bot/bot.js @@ -0,0 +1,128 @@ +#!/usr/bin/env node +/** + * Pezkuwi Auto-Payout Bot + * + * Periodically calls staking.payoutStakersByPage for all validators + * in completed eras that haven't been claimed yet. + * + * Environment: + * ASSET_HUB_RPC - WebSocket RPC endpoint (default: wss://asset-hub-rpc.pezkuwichain.io) + * MNEMONIC_FILE - Path to file containing the payer mnemonic + * INTERVAL_MS - Check interval in ms (default: 600000 = 10 min) + */ + +const { ApiPromise, WsProvider, Keyring } = require("@pezkuwi/api"); +const fs = require("fs"); + +const RPC = process.env.ASSET_HUB_RPC || "wss://asset-hub-rpc.pezkuwichain.io"; +const MNEMONIC_FILE = process.env.MNEMONIC_FILE || "/run/secrets/payout_mnemonic"; +const INTERVAL = parseInt(process.env.INTERVAL_MS || "600000", 10); + +function log(msg) { + console.log(`[${new Date().toISOString()}] ${msg}`); +} + +async function main() { + const mnemonic = fs.readFileSync(MNEMONIC_FILE, "utf8").trim(); + const kr = new Keyring({ type: "sr25519", ss58Format: 42 }); + const pair = kr.addFromMnemonic(mnemonic); + log(`Payer account: ${pair.address}`); + + let api; + + async function connect() { + if (api && api.isConnected) return; + log(`Connecting to ${RPC}...`); + api = await ApiPromise.create({ + provider: new WsProvider(RPC, 5000), + noInitWarn: true, + }); + log("Connected"); + } + + async function runPayouts() { + await connect(); + + const activeEraOpt = await api.query.staking.activeEra(); + if (activeEraOpt.isNone) { + log("No active era found"); + return; + } + const activeEra = activeEraOpt.unwrap().index.toNumber(); + + const validators = await api.query.staking.validators.entries(); + const valAddrs = validators.map(([k]) => k.args[0].toString()); + + let totalSent = 0; + let nonce = (await api.rpc.system.accountNextIndex(pair.address)).toNumber(); + + // Check all completed eras (activeEra is current, so 0..activeEra-1 are complete) + for (let era = Math.max(0, activeEra - 84); era < activeEra; era++) { + const eraReward = await api.query.staking.erasValidatorReward(era); + if (eraReward.isNone) continue; + + for (const addr of valAddrs) { + // Check if validator had exposure in this era + const overview = await api.query.staking.erasStakersOverview(era, addr); + if (overview.isNone || overview.toJSON() === null) continue; + + const pageCount = overview.unwrap().pageCount.toNumber(); + + // Check each page + for (let page = 0; page < pageCount; page++) { + const claimed = await api.query.staking.claimedRewards(era, addr); + const claimedPages = claimed.toJSON(); + if (claimedPages.includes(page)) continue; + + // Unclaimed page — send payout + try { + const tx = api.tx.staking.payoutStakersByPage(addr, era, page); + await tx.signAndSend(pair, { nonce: nonce++ }); + totalSent++; + log( + `Payout: era=${era} validator=${addr.substring(0, 16)}... page=${page}` + ); + } catch (e) { + log(`Error: era=${era} ${addr.substring(0, 16)}... page=${page}: ${e.message}`); + // Refresh nonce on error + nonce = ( + await api.rpc.system.accountNextIndex(pair.address) + ).toNumber(); + } + } + } + } + + if (totalSent > 0) { + log(`Sent ${totalSent} payouts for era(s) up to ${activeEra - 1}`); + } else { + log(`All eras claimed (active era: ${activeEra})`); + } + } + + // Initial run + try { + await runPayouts(); + } catch (e) { + log(`Error in initial run: ${e.message}`); + } + + // Periodic runs + setInterval(async () => { + try { + await runPayouts(); + } catch (e) { + log(`Error: ${e.message}`); + // Force reconnect on next run + try { + await api.disconnect(); + } catch (_) {} + api = null; + } + }, INTERVAL); +} + +main().catch((e) => { + console.error("Fatal:", e); + process.exit(1); +});