mirror of
https://github.com/pezkuwichain/pezkuwi-subquery.git
synced 2026-04-22 01:57:58 +00:00
a4a5314f79
- 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
129 lines
3.9 KiB
JavaScript
129 lines
3.9 KiB
JavaScript
#!/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);
|
|
});
|