// The faucet: pays due drips from the dedicated hot wallet. Amoy first, mainnet when Marty flips it. // // HUNT_WALLET_KEY the hot wallet's private key (env only; never on disk in the repo or volume) // HUNT_RPC e.g. https://polygon-amoy-bor-rpc.publicnode.com (Amoy) or a mainnet RPC // HUNT_CHAIN_ID 80002 (Amoy) or 137 (mainnet) // // Every send is one plain POL transfer, recorded on the ledger with its tx hash. A drip whose hash // is on the ledger is never sent again: it is settled from its receipt on a later tick if the wait // timed out or the process restarted. A send that produced no hash marks the entry 'failed' with the // reason and never retries by itself (a human looks). The balance is // read on every tick; below lowBalancePol the alert fires once per day. 'use strict'; const { ethers } = require('ethers'); const rewards = require('./rewards'); const store = require('./store'); let provider = null, wallet = null, ticking = false; function enabled() { return !!(process.env.HUNT_WALLET_KEY && process.env.HUNT_RPC); } function init() { if (!enabled()) return false; rpcs = String(process.env.HUNT_RPC).split(',').map(s => s.trim()).filter(Boolean); rpcAt = 0; connect(); return true; } let rpcs = [], rpcAt = 0; function connect() { provider = new ethers.JsonRpcProvider(rpcs[rpcAt % rpcs.length], Number(process.env.HUNT_CHAIN_ID) || undefined); wallet = new ethers.Wallet(process.env.HUNT_WALLET_KEY.trim(), provider); } // a node hiccup is not a failed drip: 5xx, timeouts, connection resets, rate limits, missing responses const TRANSIENT = /server response 5\d\d|timeout|ETIMEDOUT|ECONNRESET|ECONNREFUSED|EAI_AGAIN|429|rate limit|missing response|could not detect network|bad response|SERVER_ERROR|NETWORK_ERROR/i; function rotate(reason) { if (rpcs.length > 1) { rpcAt = (rpcAt + 1) % rpcs.length; connect(); console.warn('faucet: RPC rotated to', rpcs[rpcAt % rpcs.length], 'after', reason.slice(0, 80)); } } function address() { return wallet ? wallet.address : null; } async function balance() { if (!provider || !wallet) return null; const b = await provider.getBalance(wallet.address); return Number(ethers.formatEther(b)); } // a drip is settled from its receipt: paid on status 1, failed on a revert async function settle(p, rc, notify) { const ok = rc && rc.status === 1; rewards.mark(p.id, { status: ok ? 'paid' : 'failed', paidAt: Date.now(), error: ok ? null : 'reverted' }); if (ok && notify) await notify('paid', Object.assign({}, p, { tx: p.tx })); return ok; } async function tick(notify) { if (!wallet || ticking) return { paid: 0 }; ticking = true; let paid = 0; try { // drips sent but never settled here (a restart mid-wait, a receipt that lagged past the wait): // ask the chain, never re-send for (const p of store.read('payouts', []).filter(x => x.status === 'sent' && x.tx)) { try { const rc = await provider.getTransactionReceipt(p.tx); if (rc && (await settle(p, rc, notify))) paid++; } catch (e) {} } const due = rewards.payable(); // a dry faucet is not a failed drip: check the balance first, leave the drips due, alert once let bal0 = null; try { bal0 = await balance(); } catch (e) {} if (bal0 != null && due.length) { const need = due.reduce((n, p) => n + p.pol, 0) + 0.01; if (bal0 < due[0].pol + 0.005) { if (notify) { const st = store.read('faucet-state', {}); const day = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Chicago' }); if (st.dryAlertDay !== day) { st.dryAlertDay = day; store.write('faucet-state', st); await notify('low', { balance: bal0, threshold: need, address: wallet.address }); } } ticking = false; return { paid: 0, waiting: due.length, balance: bal0 }; } } for (const p of due) { if (bal0 != null && bal0 < p.pol + 0.005) break; // pay what the balance covers, leave the rest due if (!p.wallet || !/^0x[a-f0-9]{40}$/i.test(p.wallet)) { rewards.mark(p.id, { status: 'failed', error: 'no wallet on the account' }); continue; } try { const tx = await wallet.sendTransaction({ to: p.wallet, value: ethers.parseEther(String(p.pol)) }); rewards.mark(p.id, { status: 'sent', tx: tx.hash }); let rc = null; try { rc = await tx.wait(1, 90000); } catch (e) { if (e && e.code === 'TIMEOUT') continue; throw e; } // still pending: stays 'sent', settled next tick if (await settle(Object.assign({}, p, { tx: tx.hash }), rc, notify)) { paid++; if (bal0 != null) bal0 -= p.pol; } } catch (e) { const msg = String(e.message || e); if (/insufficient funds/i.test(msg)) { rewards.mark(p.id, { status: 'due', error: null }); break; } // dry: leave it due, stop this pass // the send itself failed only if no hash was recorded; a hash means the chain has it, leave it 'sent' const cur = store.read('payouts', []).find(x => x.id === p.id); if (cur && cur.status === 'sent' && cur.tx) continue; if (TRANSIENT.test(msg)) { rewards.mark(p.id, { status: 'due', error: 'node: ' + msg.slice(0, 120) }); console.warn('faucet: transient, left due:', p.id, msg.slice(0, 120)); rotate(msg); break; } // next tick, next node rewards.mark(p.id, { status: 'failed', error: msg.slice(0, 200) }); if (notify) await notify('failed', Object.assign({}, p, { error: String(e.message || e).slice(0, 200) })); } } // low-balance alert, once a day const bal = await balance(); if (bal != null) { const s = rewards.settings(); const st = store.read('faucet-state', {}); const day = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Chicago' }); if (bal < Number(s.lowBalancePol) && st.lowAlertDay !== day) { st.lowAlertDay = day; store.write('faucet-state', st); if (notify) await notify('low', { balance: bal, threshold: s.lowBalancePol, address: wallet.address }); } store.update('faucet-state', {}, x => Object.assign(x, { balance: bal, checkedAt: Date.now() })); } } finally { ticking = false; } return { paid }; } module.exports = { enabled, init, address, balance, tick };