init
This commit is contained in:
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
*.log
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.env
|
||||||
|
test
|
||||||
|
*.md
|
||||||
|
.claude
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
15
Dockerfile
Normal file
15
Dockerfile
Normal 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
17
docker-compose.yml
Normal 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
112
package-lock.json
generated
Normal 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
12
package.json
Normal 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
84
src/calculations.js
Normal 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
37
src/config.js
Normal 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
159
src/redis.js
Normal 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
151
src/server.js
Normal 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
74
src/sources.js
Normal 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
125
src/vault.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user