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