// 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'); let chain = null, ads = 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; } 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 = state.inflight || {}; if (state.inflight[b.id] && Date.now() - state.inflight[b.id] < 10 * 60000) continue; // sent recently, answer lost: wait for the event // 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) { state.skipped[b.id] = String(e.reason || e.shortMessage || e.message).slice(0, 120); continue; } const g = await chain.suggestedFees(); const overrides = { gasLimit: 120000n, maxPriorityFeePerGas: BigInt(g.maxPriorityFeePerGas), maxFeePerGas: BigInt(g.maxFeePerGas) }; state.inflight[b.id] = Date.now(); 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]; 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; setup().catch(e => { state.lastError = e.message; }); } module.exports = { init, tick, status };