This commit is contained in:
ZOMBIIIIIII
2026-04-29 22:00:26 +03:00
commit 5e0e8f73c5
12 changed files with 799 additions and 0 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
*.log
.git
.gitignore
Dockerfile
docker-compose.yml
.env
test
*.md
.claude

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
*.log
.env

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY src ./src
ENV NODE_ENV=production \
PORT=3004
EXPOSE 3004
CMD ["node", "src/server.js"]

17
docker-compose.yml Normal file
View File

@@ -0,0 +1,17 @@
services:
gweiparser:
build: .
image: gweiparser:latest
container_name: gweiparser
restart: unless-stopped
env_file: .env
environment:
PORT: 3004
ports:
- "3004:3004"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3004/ping').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s

112
package-lock.json generated Normal file
View File

@@ -0,0 +1,112 @@
{
"name": "gwei-parser",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gwei-parser",
"version": "1.0.0",
"dependencies": {
"redis": "^5.8.2"
}
},
"node_modules/@redis/bloom": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz",
"integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==",
"license": "MIT",
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@redis/client": "^5.12.1"
}
},
"node_modules/@redis/client": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz",
"integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==",
"license": "MIT",
"dependencies": {
"cluster-key-slot": "1.1.2"
},
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@node-rs/xxhash": "^1.1.0",
"@opentelemetry/api": ">=1 <2"
},
"peerDependenciesMeta": {
"@node-rs/xxhash": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/@redis/json": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz",
"integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==",
"license": "MIT",
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@redis/client": "^5.12.1"
}
},
"node_modules/@redis/search": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz",
"integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==",
"license": "MIT",
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@redis/client": "^5.12.1"
}
},
"node_modules/@redis/time-series": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz",
"integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==",
"license": "MIT",
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@redis/client": "^5.12.1"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/redis": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz",
"integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==",
"license": "MIT",
"dependencies": {
"@redis/bloom": "5.12.1",
"@redis/client": "5.12.1",
"@redis/json": "5.12.1",
"@redis/search": "5.12.1",
"@redis/time-series": "5.12.1"
},
"engines": {
"node": ">= 18.19.0"
}
}
}
}

12
package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "gwei-parser",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.js"
},
"dependencies": {
"redis": "^5.8.2"
}
}

84
src/calculations.js Normal file
View File

@@ -0,0 +1,84 @@
const WEI_PER_GWEI = 1_000_000_000;
const SLOW_REWARD_INDEX = 0;
export function roundUpToTenth(value) {
if (!Number.isFinite(value)) {
throw new TypeError('Expected a finite number');
}
return Math.ceil((value - Number.EPSILON) * 10) / 10;
}
export function calculateCostRub({ gwei, gasLimit, ethUsd, usdRub }) {
const ethCost = (gwei * gasLimit) / WEI_PER_GWEI;
const rubCost = ethCost * ethUsd * usdRub;
return roundUpToTenth(rubCost);
}
export function createGasPayload({
feeHistory,
gasLimit,
ethUsd,
usdRub,
now = new Date(),
}) {
const gasModes = parseFeeHistory(feeHistory);
return {
network: 'ethereum-mainnet',
gasLimit,
updatedAt: now.toISOString(),
status: 'fresh',
error: null,
rates: { ethUsd, usdRub },
modes: Object.fromEntries(
Object.entries(gasModes).map(([mode, gwei]) => [
mode,
{
gwei,
costRub: calculateCostRub({ gwei, gasLimit, ethUsd, usdRub }),
},
]),
),
};
}
export function parseFeeHistory(feeHistory) {
const baseFees = feeHistory?.baseFeePerGas;
const rewards = feeHistory?.reward;
if (!Array.isArray(baseFees) || baseFees.length === 0) {
throw new Error('feeHistory.baseFeePerGas is missing');
}
if (!Array.isArray(rewards) || rewards.length === 0) {
throw new Error('feeHistory.reward is missing');
}
const nextBaseFeeGwei = weiHexToGwei(baseFees.at(-1));
const latestRewards = rewards.at(-1);
if (!Array.isArray(latestRewards) || latestRewards.length < 1) {
throw new Error('feeHistory.reward must include at least the slow percentile');
}
const slowPriorityFeeGwei = weiHexToGwei(latestRewards[SLOW_REWARD_INDEX]);
const slow = roundUpToTenth(nextBaseFeeGwei + slowPriorityFeeGwei);
const fast = roundUpToTenth(slow * 2);
const normal = roundUpToTenth((slow + fast) / 2);
return {
slow,
normal,
fast,
};
}
function weiHexToGwei(hexValue) {
if (typeof hexValue !== 'string' || !hexValue.startsWith('0x')) {
throw new Error(`Invalid wei hex value: ${hexValue}`);
}
return Number(BigInt(hexValue)) / WEI_PER_GWEI;
}

37
src/config.js Normal file
View File

@@ -0,0 +1,37 @@
try {
process.loadEnvFile?.('.env');
} catch (error) {
if (error?.code !== 'ENOENT') {
throw error;
}
}
function numberFromEnv(name, fallback) {
const rawValue = process.env[name];
if (rawValue === undefined || rawValue === '') {
return fallback;
}
const parsed = Number(rawValue);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`${name} must be a positive number`);
}
return parsed;
}
export const config = {
port: numberFromEnv('PORT', 3000),
rpcUrl: process.env.ETH_RPC_URL || 'https://ethereum-rpc.publicnode.com',
redisUrl: process.env.REDIS_URL || 'redis://localhost:6379',
redisKeyPrefix: process.env.REDIS_KEY_PREFIX || 'gwei',
vaultAddr: process.env.VAULT_ADDR || '',
vaultMountPoint: process.env.VAULT_MOUNT_POINT || '',
vaultRoleId: process.env.VAULT_ROLE_ID || '',
vaultSecretId: process.env.VAULT_SECRET_ID || '',
vaultKeydbSecretPath: process.env.VAULT_KEYDB_SECRET_PATH || 'keydb',
gasLimit: numberFromEnv('GAS_LIMIT', 65000),
gasRefreshMs: numberFromEnv('GAS_REFRESH_MS', 30000),
ratesRefreshMs: numberFromEnv('RATES_REFRESH_MS', 60000),
requestTimeoutMs: numberFromEnv('REQUEST_TIMEOUT_MS', 10000),
};

159
src/redis.js Normal file
View File

@@ -0,0 +1,159 @@
import { createClient } from 'redis';
const MODE_NAMES = ['slow', 'normal', 'fast'];
export function buildRedisUrl({
host,
port,
password,
database,
scheme = 'redis',
}) {
if (!host) {
throw new Error('KeyDB host is required');
}
const url = new URL(`${scheme}://localhost`);
url.hostname = host;
if (port !== undefined && port !== null && port !== '') {
url.port = String(port);
}
if (password) {
url.password = password;
}
if (database !== undefined && database !== null && database !== '') {
url.pathname = `/${database}`;
}
return url.toString();
}
export function sanitizeRedisUrl(redisUrl) {
const url = new URL(redisUrl);
if (url.password) {
url.password = '***';
}
return url.toString();
}
export function buildRedisModeHash(payload, { keyPrefix = 'gwei' } = {}) {
if (!payload?.modes) {
throw new Error('Payload does not contain modes');
}
const fields = {};
for (const mode of MODE_NAMES) {
const gwei = payload.modes?.[mode]?.gwei;
const costRub = payload.modes?.[mode]?.costRub;
if (!Number.isFinite(gwei)) {
throw new Error(`Payload does not contain a numeric ${mode} gwei value`);
}
if (!Number.isFinite(costRub)) {
throw new Error(`Payload does not contain a numeric ${mode} costRub value`);
}
fields[mode] = gwei.toFixed(1);
fields[`${mode}_rub`] = costRub.toFixed(1);
}
const ethUsd = payload.rates?.ethUsd;
const usdRub = payload.rates?.usdRub;
if (Number.isFinite(ethUsd)) {
fields.eth_usd = ethUsd.toFixed(2);
}
if (Number.isFinite(usdRub)) {
fields.usd_rub = usdRub.toFixed(4);
}
if (Number.isFinite(payload.gasLimit)) {
fields.gas_limit = String(payload.gasLimit);
}
fields.ts = payload.updatedAt || new Date().toISOString();
return {
key: `${keyPrefix}:eth:last`,
fields,
};
}
export class RedisModeWriter {
constructor({ url, keyPrefix = 'gwei', logger = console }) {
this.url = url;
this.keyPrefix = keyPrefix;
this.logger = logger;
this.client = null;
this.connecting = null;
}
async writeModes(payload) {
await this.connect();
const { key, fields } = buildRedisModeHash(payload, {
keyPrefix: this.keyPrefix,
});
await this.client.hSet(key, fields);
this.logger.log(`Redis updated hash ${key} (${Object.keys(fields).length} fields)`);
}
async close() {
if (!this.client) {
return;
}
const client = this.client;
this.client = null;
this.connecting = null;
await client.quit().catch(() => client.disconnect());
}
async connect() {
if (this.client?.isReady) {
return this.client;
}
if (this.connecting) {
return this.connecting;
}
const client = createClient({ url: this.url });
client.on('error', (error) => {
this.logger.warn(`Redis connection error: ${describeError(error)}`);
});
this.connecting = client.connect().then(() => {
this.client = client;
this.connecting = null;
this.logger.log(`Redis writer connected to ${sanitizeRedisUrl(this.url)}`);
return client;
}).catch((error) => {
this.connecting = null;
client.disconnect();
throw error;
});
return this.connecting;
}
}
function describeError(error) {
if (error instanceof Error && error.message) {
return error.message;
}
if (error?.code) {
return error.code;
}
return String(error || 'unknown error');
}

151
src/server.js Normal file
View File

@@ -0,0 +1,151 @@
import http from 'node:http';
import { createGasPayload } from './calculations.js';
import { config } from './config.js';
import { RedisModeWriter, buildRedisUrl } from './redis.js';
import { fetchFeeHistory, fetchRates } from './sources.js';
import { fetchKeyDbConfigFromVault } from './vault.js';
const state = {
rates: null,
lastRatesRefreshAt: 0,
};
let redisWriter = null;
let tickTimer = null;
const server = http.createServer((request, response) => {
if (request.method !== 'GET') {
sendText(response, 405, 'Method Not Allowed');
return;
}
const url = new URL(request.url, `http://${request.headers.host || 'localhost'}`);
if (url.pathname === '/ping') {
sendJson(response, 200, { status: 'ok' });
return;
}
sendText(response, 404, 'Not Found');
});
start().catch((error) => {
console.error(`Startup failed: ${describeError(error)}`);
process.exit(1);
});
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
async function start() {
redisWriter = await createRedisWriter();
await redisWriter.connect();
await tick();
tickTimer = setInterval(() => {
tick().catch((error) => {
console.error(`Gas refresh failed: ${describeError(error)}`);
});
}, config.gasRefreshMs);
server.listen(config.port, () => {
console.log(`Gwei parser listening on http://localhost:${config.port}`);
});
}
async function tick() {
try {
await ensureFreshRates();
const feeHistory = await fetchFeeHistory({
rpcUrl: config.rpcUrl,
timeoutMs: config.requestTimeoutMs,
});
const payload = createGasPayload({
feeHistory,
gasLimit: config.gasLimit,
ethUsd: state.rates.ethUsd,
usdRub: state.rates.usdRub,
});
await redisWriter.writeModes(payload);
} catch (error) {
console.error(`Gas refresh failed: ${describeError(error)}`);
}
}
async function ensureFreshRates() {
const ratesAgeMs = Date.now() - state.lastRatesRefreshAt;
if (state.rates && ratesAgeMs < config.ratesRefreshMs) {
return;
}
state.rates = await fetchRates({ timeoutMs: config.requestTimeoutMs });
state.lastRatesRefreshAt = Date.now();
}
async function createRedisWriter() {
if (config.vaultAddr && config.vaultMountPoint && config.vaultRoleId && config.vaultSecretId) {
const keydbConfig = await fetchKeyDbConfigFromVault({
vaultAddr: config.vaultAddr,
mountPoint: config.vaultMountPoint,
roleId: config.vaultRoleId,
secretId: config.vaultSecretId,
secretPath: config.vaultKeydbSecretPath,
timeoutMs: config.requestTimeoutMs,
});
const url = buildRedisUrl(keydbConfig);
console.log(
`Resolved KeyDB credentials from Vault ${config.vaultMountPoint}/${config.vaultKeydbSecretPath}`,
);
return new RedisModeWriter({
url,
keyPrefix: config.redisKeyPrefix,
});
}
return new RedisModeWriter({
url: config.redisUrl,
keyPrefix: config.redisKeyPrefix,
});
}
async function shutdown() {
console.log('Shutting down...');
if (tickTimer) {
clearInterval(tickTimer);
tickTimer = null;
}
server.close();
await redisWriter?.close();
process.exit(0);
}
function sendJson(response, statusCode, data) {
response.writeHead(statusCode, {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-store',
});
response.end(JSON.stringify(data));
}
function sendText(response, statusCode, body) {
response.writeHead(statusCode, {
'content-type': 'text/plain; charset=utf-8',
});
response.end(body);
}
function describeError(error) {
if (error instanceof Error && error.message) {
return error.message;
}
if (error?.code) {
return error.code;
}
return String(error || 'unknown error');
}

74
src/sources.js Normal file
View File

@@ -0,0 +1,74 @@
const FEE_HISTORY_BLOCK_COUNT = '0x5';
const FEE_HISTORY_REWARD_PERCENTILES = [10, 50, 90];
const ETH_USD_SPOT_URL = 'https://api.coinbase.com/v2/prices/ETH-USD/spot';
const USD_RATES_URL = 'https://api.coinbase.com/v2/exchange-rates?currency=USD';
export async function fetchFeeHistory({ rpcUrl, timeoutMs }) {
const response = await fetchJson(rpcUrl, {
timeoutMs,
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'eth_feeHistory',
params: [FEE_HISTORY_BLOCK_COUNT, 'latest', FEE_HISTORY_REWARD_PERCENTILES],
}),
});
if (response.error) {
throw new Error(`Ethereum RPC error: ${response.error.message || response.error.code}`);
}
if (!response.result) {
throw new Error('Ethereum RPC response did not include result');
}
return response.result;
}
export async function fetchRates({ timeoutMs }) {
const [ethUsdResponse, usdRatesResponse] = await Promise.all([
fetchJson(ETH_USD_SPOT_URL, { timeoutMs }),
fetchJson(USD_RATES_URL, { timeoutMs }),
]);
const ethUsd = Number(ethUsdResponse?.data?.amount);
const usdRub = Number(usdRatesResponse?.data?.rates?.RUB);
if (!Number.isFinite(ethUsd) || ethUsd <= 0) {
throw new Error('Coinbase ETH/USD response is invalid');
}
if (!Number.isFinite(usdRub) || usdRub <= 0) {
throw new Error('Coinbase USD/RUB response is invalid');
}
return { ethUsd, usdRub };
}
async function fetchJson(url, { timeoutMs, ...options } = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
headers: {
accept: 'application/json',
...(options.headers || {}),
},
});
if (!response.ok) {
throw new Error(`${url} returned HTTP ${response.status}`);
}
return await response.json();
} finally {
clearTimeout(timeout);
}
}

125
src/vault.js Normal file
View File

@@ -0,0 +1,125 @@
export async function fetchKeyDbConfigFromVault({
vaultAddr,
mountPoint,
roleId,
secretId,
secretPath = 'keydb',
timeoutMs = 10000,
}) {
const token = await loginWithAppRole({
vaultAddr,
roleId,
secretId,
timeoutMs,
});
const secret = await fetchKvV2Secret({
vaultAddr,
mountPoint,
secretPath,
token,
timeoutMs,
});
return normalizeKeyDbSecret(secret);
}
async function loginWithAppRole({ vaultAddr, roleId, secretId, timeoutMs }) {
const response = await fetchJson(`${vaultAddr}/v1/auth/approle/login`, {
timeoutMs,
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
role_id: roleId,
secret_id: secretId,
}),
});
const token = response?.auth?.client_token;
if (!token) {
throw new Error('Vault AppRole login did not return a client token');
}
return token;
}
async function fetchKvV2Secret({
vaultAddr,
mountPoint,
secretPath,
token,
timeoutMs,
}) {
const response = await fetchJson(
`${vaultAddr}/v1/${mountPoint}/data/${secretPath}`,
{
timeoutMs,
headers: {
'X-Vault-Token': token,
},
},
);
const secret = response?.data?.data;
if (!secret) {
throw new Error(`Vault secret ${mountPoint}/${secretPath} does not contain data`);
}
return secret;
}
function normalizeKeyDbSecret(secret) {
const host = secret.host;
const password = secret.password;
const port = Number(secret.port);
const database = Number(secret.database);
if (!host) {
throw new Error('Vault KeyDB secret is missing host');
}
if (!password) {
throw new Error('Vault KeyDB secret is missing password');
}
if (!Number.isFinite(port) || port <= 0) {
throw new Error('Vault KeyDB secret has an invalid port');
}
if (!Number.isFinite(database) || database < 0) {
throw new Error('Vault KeyDB secret has an invalid database');
}
return {
host,
password,
port,
database,
};
}
async function fetchJson(url, { timeoutMs, ...options } = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
headers: {
accept: 'application/json',
...(options.headers || {}),
},
});
if (!response.ok) {
throw new Error(`Vault request failed with HTTP ${response.status}`);
}
return await response.json();
} finally {
clearTimeout(timeout);
}
}