Files

139 lines
8.9 KiB
JavaScript

// Automatic credit burner: settles pending campaign spend on-chain by calling
// consume() from the engine signer. Inert unless ENGINE_KEY (hex private key)
// or ENGINE_KEY_FILE is set. Purchased credits only ever go DOWN via this path,
// and members never sign or pay gas for it: the engine wallet pays.
let ethers = null; try { ethers = require('ethers'); } catch (e) { /* optional dependency */ }
const fs = require('fs');
const path = require('path');
// in-flight sends survive a restart: written to the volume before every send, cleared on receipt
const INFLIGHT_FILE = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'burner-inflight.json');
function loadInflight() { try { return JSON.parse(fs.readFileSync(INFLIGHT_FILE(), 'utf8')); } catch (e) { return {}; } }
function saveInflight(o) { try { fs.writeFileSync(INFLIGHT_FILE(), JSON.stringify(o)); } catch (e) {} }
let chain = null, ads = null, accounts = null;
// another of the same account's positions that can cover this burn on-chain (credits are pooled per account)
async function fundedAlternative(b) {
if (!accounts) return null;
const owner = await ads.burnOwner(b.ref); if (!owner) return null;
const acct = await accounts.byEmail(owner); if (!acct) return null;
const ids = [acct.memberId, ...(await accounts.positions(owner)).map(p => p.memberId)].filter(id => id && id !== b.memberId);
for (const id of [...new Set(ids)]) {
try { const bal = await chain.creditBalance(id, 0); const held = await ads.unburnedFor(id); if (bal - held >= b.amount) return id; } catch (e) {}
}
return null;
}
const ABI = ['function consume(uint32 memberId_, uint8 creditType, uint256 amount, bytes32 campaignRef)', 'function engineSigner() view returns (address)'];
const state = { enabled: false, address: null, signer: null, balanceWei: '0', lastRun: 0, lastError: null, burned: 0, lastTx: null, mismatch: false, skipped: {} };
let running = false;
function keyHex() {
let k = String(process.env.ENGINE_KEY || '').trim();
if (!k && process.env.ENGINE_KEY_FILE) { try { k = fs.readFileSync(process.env.ENGINE_KEY_FILE, 'utf8').trim(); } catch (e) {} }
if (!k) return null;
if (!k.startsWith('0x')) k = '0x' + k;
return /^0x[0-9a-fA-F]{64}$/.test(k) ? k : null;
}
let rpcIdx = 0;
function provider() {
const c = chain.getConfig();
const urls = (c.rpcs && c.rpcs.length) ? c.rpcs : [c.rpc];
return new ethers.JsonRpcProvider(urls[rpcIdx % urls.length], Number(c.chainId), { staticNetwork: true });
}
function rotateRpc() { const c = chain.getConfig(); const n = (c.rpcs && c.rpcs.length) || 1; rpcIdx = (rpcIdx + 1) % n; }
async function setup() {
const k = keyHex();
if (!k || !ethers) { state.enabled = false; return; }
const w = new ethers.Wallet(k, provider());
state.address = w.address; state.enabled = true;
try {
const ctr = new ethers.Contract(chain.getConfig().contract, ABI, w);
const es = await ctr.engineSigner();
state.mismatch = String(es).toLowerCase() !== w.address.toLowerCase();
if (state.mismatch) console.error('burner: ENGINE_KEY address', w.address, 'is not the contract engineSigner', es, '- burns will revert; disabled');
} catch (e) { state.lastError = 'engineSigner read: ' + e.message; }
}
function refToBytes32(ref) { return ethers.zeroPadBytes(ethers.toUtf8Bytes(String(ref || '').slice(0, 32)), 32); }
// the on-chain ref IS the burn id, so a burn that already mined can always be recognised
// from its CreditsConsumed event, even when the RPC lost the response (a lost response
// double-burned member #5's campaign 30 on 2026-09-10)
function alreadyMined(b) {
const want = refToBytes32(b.id).toLowerCase();
const hit = chain.recentEvents(1e9).find(e => e.type === 'CreditsConsumed' && e.memberId === Number(b.memberId) && String(e.ref || '').toLowerCase() === want);
return hit ? hit.tx : null;
}
// ask the chain directly (not the index) whether this burn's ref already appears in a
// CreditsConsumed log for this member over roughly the last two hours
const TOPIC_CONSUMED = '0x97f58994fda6236f3659a1d723c3d42e81841551eae9243477a2948eac6aec46';
async function minedOnChain(b) {
const want = refToBytes32(b.id).toLowerCase();
const latest = parseInt(await chain.rpc('eth_blockNumber', []), 16);
const from = Math.max(0, latest - 3600);
const member = '0x' + Number(b.memberId).toString(16).padStart(64, '0');
const logs = await chain.rpc('eth_getLogs', [{ address: chain.getConfig().contract, fromBlock: '0x' + from.toString(16), toBlock: 'latest', topics: [TOPIC_CONSUMED, member] }]);
for (const lg of logs || []) {
const d = String(lg.data || '').slice(2);
const ref = '0x' + d.slice(128, 192);
if (ref.toLowerCase() === want) return lg.transactionHash;
}
return null;
}
async function tick() {
if (!state.enabled || state.mismatch || running || !ethers) return { burned: 0 };
running = true;
let burned = 0;
try {
const w = new ethers.Wallet(keyHex(), provider());
const ctr = new ethers.Contract(chain.getConfig().contract, ABI, w);
state.balanceWei = (await w.provider.getBalance(w.address)).toString();
const pending = await ads.pendingBurns();
state.lastRun = Date.now();
if (BigInt(state.balanceWei) < ethers.parseEther('0.05')) { state.lastError = 'engine wallet low on POL for gas'; return { burned: 0 }; }
for (const b of pending.slice(0, 20)) {
try {
// Polygon nodes reject low priority fees and some public RPCs answer fee
// queries with 500s: set the fees ourselves from the server's estimate
const mined = alreadyMined(b);
if (mined) { await ads.markBurned(b.id, mined); burned += 1; state.burned += 1; continue; }
state.inflight = Object.assign(loadInflight(), state.inflight || {});
if (state.inflight[b.id]) {
// a send whose answer we lost (or a restart mid-send): ask the chain itself before doing anything
let tx = null; try { tx = await minedOnChain(b); } catch (e) { state.lastError = 'chain check: ' + e.message; break; }
if (tx) { await ads.markBurned(b.id, tx); delete state.inflight[b.id]; saveInflight(state.inflight); burned += 1; state.burned += 1; continue; }
if (Date.now() - state.inflight[b.id] < 10 * 60000) continue; // give the network time; re-check next tick
}
// dry-run first: a revert here (usually "Insufficient credits", the member's on-chain
// balance is below what the engine metered) costs no gas and is left for the admin
try { await ctr.consume.staticCall(Number(b.memberId), 0, BigInt(b.amount), refToBytes32(b.id)); }
catch (e) {
const why = String(e.reason || e.shortMessage || e.message).slice(0, 120);
// the pinned position is dry: settle from another funded position on the same account
const alt = /insufficient credits/i.test(why) ? await fundedAlternative(b) : null;
if (!alt) { state.skipped[b.id] = why + (alt === null && /insufficient credits/i.test(why) ? ' (no funded position on the account)' : ''); continue; }
try { await ctr.consume.staticCall(Number(alt), 0, BigInt(b.amount), refToBytes32(b.id)); }
catch (e2) { state.skipped[b.id] = String(e2.reason || e2.shortMessage || e2.message).slice(0, 120); continue; }
await ads.reassignBurn(b.id, alt); b.memberId = alt; delete state.skipped[b.id];
}
const g = await chain.suggestedFees();
const overrides = { gasLimit: 120000n, maxPriorityFeePerGas: BigInt(g.maxPriorityFeePerGas), maxFeePerGas: BigInt(g.maxFeePerGas) };
state.inflight[b.id] = Date.now(); saveInflight(state.inflight);
const tx = await ctr.consume(Number(b.memberId), 0, BigInt(b.amount), refToBytes32(b.id), overrides);
const rc = await tx.wait(1);
if (rc && rc.status === 1) { await ads.markBurned(b.id, tx.hash); delete state.inflight[b.id]; saveInflight(state.inflight); burned += 1; state.burned += 1; state.lastTx = tx.hash; state.lastError = null; }
else { state.lastError = 'consume reverted for burn ' + b.id; break; }
} catch (e) {
state.lastError = 'burn ' + b.id + ': ' + String(e.shortMessage || e.message).slice(0, 160);
// a node error (500, timeout, rate limit): move to the next RPC for the next tick.
// an "Insufficient credits" revert means the member's on-chain balance is
// already lower than the engine thinks; leave it pending for the admin to review
if (/server response|timeout|rate|429|503|502|500/i.test(String(e.message))) { rotateRpc(); break; }
state.skipped[b.id] = String(e.reason || e.shortMessage || e.message).slice(0, 120);
}
}
} finally { running = false; }
return { burned };
}
function status() { return Object.assign({}, state, { hasEthers: !!ethers, keyPresent: !!keyHex() }); }
function init(opts) { chain = opts.chain; ads = opts.ads; accounts = opts.accounts || null; setup().catch(e => { state.lastError = e.message; }); }
module.exports = { init, tick, status };