Burner: in-flight sends persisted to the volume and checked directly against contract logs before any resend
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,11 @@
|
|||||||
// and members never sign or pay gas for it: the engine wallet pays.
|
// and members never sign or pay gas for it: the engine wallet pays.
|
||||||
let ethers = null; try { ethers = require('ethers'); } catch (e) { /* optional dependency */ }
|
let ethers = null; try { ethers = require('ethers'); } catch (e) { /* optional dependency */ }
|
||||||
const fs = require('fs');
|
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;
|
let chain = null, ads = null;
|
||||||
const ABI = ['function consume(uint32 memberId_, uint8 creditType, uint256 amount, bytes32 campaignRef)', 'function engineSigner() view returns (address)'];
|
const ABI = ['function consume(uint32 memberId_, uint8 creditType, uint256 amount, bytes32 campaignRef)', 'function engineSigner() view returns (address)'];
|
||||||
@@ -45,6 +50,22 @@ function alreadyMined(b) {
|
|||||||
const hit = chain.recentEvents(1e9).find(e => e.type === 'CreditsConsumed' && e.memberId === Number(b.memberId) && String(e.ref || '').toLowerCase() === want);
|
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;
|
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() {
|
async function tick() {
|
||||||
if (!state.enabled || state.mismatch || running || !ethers) return { burned: 0 };
|
if (!state.enabled || state.mismatch || running || !ethers) return { burned: 0 };
|
||||||
@@ -63,18 +84,23 @@ async function tick() {
|
|||||||
// queries with 500s: set the fees ourselves from the server's estimate
|
// queries with 500s: set the fees ourselves from the server's estimate
|
||||||
const mined = alreadyMined(b);
|
const mined = alreadyMined(b);
|
||||||
if (mined) { await ads.markBurned(b.id, mined); burned += 1; state.burned += 1; continue; }
|
if (mined) { await ads.markBurned(b.id, mined); burned += 1; state.burned += 1; continue; }
|
||||||
state.inflight = state.inflight || {};
|
state.inflight = Object.assign(loadInflight(), state.inflight || {});
|
||||||
if (state.inflight[b.id] && Date.now() - state.inflight[b.id] < 10 * 60000) continue; // sent recently, answer lost: wait for the event
|
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
|
// 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
|
// 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)); }
|
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; }
|
catch (e) { state.skipped[b.id] = String(e.reason || e.shortMessage || e.message).slice(0, 120); continue; }
|
||||||
const g = await chain.suggestedFees();
|
const g = await chain.suggestedFees();
|
||||||
const overrides = { gasLimit: 120000n, maxPriorityFeePerGas: BigInt(g.maxPriorityFeePerGas), maxFeePerGas: BigInt(g.maxFeePerGas) };
|
const overrides = { gasLimit: 120000n, maxPriorityFeePerGas: BigInt(g.maxPriorityFeePerGas), maxFeePerGas: BigInt(g.maxFeePerGas) };
|
||||||
state.inflight[b.id] = Date.now();
|
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 tx = await ctr.consume(Number(b.memberId), 0, BigInt(b.amount), refToBytes32(b.id), overrides);
|
||||||
const rc = await tx.wait(1);
|
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; }
|
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; }
|
else { state.lastError = 'consume reverted for burn ' + b.id; break; }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state.lastError = 'burn ' + b.id + ': ' + String(e.shortMessage || e.message).slice(0, 160);
|
state.lastError = 'burn ' + b.id + ': ' + String(e.shortMessage || e.message).slice(0, 160);
|
||||||
|
|||||||
Reference in New Issue
Block a user