Skip to content

Commit 27f0ce2

Browse files
authored
Merge pull request #9 from Web3-Pi/mgordel/gas-price-warn
Gas price warning
2 parents e3d4ee1 + ed47704 commit 27f0ce2

8 files changed

Lines changed: 278 additions & 21 deletions

File tree

frontend/src/components/TransactionDialog.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,24 @@ export function TransactionDialog({
161161
)}
162162
</b>{" "}
163163
Gwei
164+
{transactionPayload.avgFeePerGas &&
165+
Number(
166+
formatUnits(
167+
transactionPayload.maxFeePerGas.toString(),
168+
"gwei",
169+
),
170+
) <
171+
Number(
172+
formatUnits(
173+
transactionPayload.avgFeePerGas?.toString() || "0",
174+
"gwei",
175+
),
176+
) *
177+
1.1 && (
178+
<span className="text-red-500 ml-2">
179+
Max fee per gas is high!
180+
</span>
181+
)}
164182
</div>
165183
</>
166184
)}
@@ -186,6 +204,21 @@ export function TransactionDialog({
186204
{formatUnits(transactionPayload.gasPrice.toString(), "gwei")}
187205
</b>{" "}
188206
Gwei
207+
{transactionPayload.avgGasPrice &&
208+
Number(
209+
formatUnits(transactionPayload.gasPrice.toString(), "gwei"),
210+
) >
211+
Number(
212+
formatUnits(
213+
transactionPayload.avgGasPrice?.toString() || "0",
214+
"gwei",
215+
),
216+
) *
217+
1.1 && (
218+
<span className="text-red-500 ml-2">
219+
Gas price is high!
220+
</span>
221+
)}
189222
</div>
190223
</>
191224
)}

frontend/src/hooks/useWebSocket.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export interface TransactionPayload {
4444
maxFeePerGas?: string;
4545
maxPriorityFeePerGas?: string;
4646
gasPrice?: string;
47+
avgGasPrice?: number;
48+
avgFeePerGas?: number;
4749
}
4850

4951
export interface UserDecision {

src/metrics/influx.ts

Lines changed: 132 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,18 @@ export interface InfluxConfig extends MetricsConfig {
1212
};
1313
}
1414

15+
interface InfluxQLResponse {
16+
series?: Array<{
17+
name: string;
18+
columns: string[];
19+
values: Array<Array<string | number | null>>;
20+
}>;
21+
error?: string;
22+
}
23+
1524
export class InfluxMetricsCollector extends MetricsCollector {
1625
private writeApi: WriteApi;
26+
private influxDb: InfluxDB;
1727

1828
constructor(
1929
private config: InfluxConfig,
@@ -22,11 +32,12 @@ export class InfluxMetricsCollector extends MetricsCollector {
2232
super(config);
2333

2434
const { url, username, password, org, bucket } = config.influx;
25-
26-
this.writeApi = new InfluxDB({
35+
this.influxDb = new InfluxDB({
2736
url,
2837
token: `${username}:${password}`,
29-
}).getWriteApi(org, bucket);
38+
});
39+
40+
this.writeApi = this.influxDb.getWriteApi(org, bucket);
3041
}
3142

3243
public async init(): Promise<void> {
@@ -58,6 +69,18 @@ export class InfluxMetricsCollector extends MetricsCollector {
5869
.stringField("txType", metric.tx.txType)
5970
.stringField("labelFrom", metric.tx.labelFrom || "")
6071
.stringField("labelTo", metric.tx.labelTo || "");
72+
73+
if (metric.tx.gasPrice) {
74+
point.floatField("gasPrice", parseFloat(metric.tx.gasPrice));
75+
} else if (metric.tx.maxFeePerGas) {
76+
point.floatField("maxFeePerGas", parseFloat(metric.tx.maxFeePerGas));
77+
if (metric.tx.maxPriorityFeePerGas) {
78+
point.floatField(
79+
"maxPriorityFeePerGas",
80+
parseFloat(metric.tx.maxPriorityFeePerGas),
81+
);
82+
}
83+
}
6184
}
6285

6386
return point;
@@ -66,12 +89,117 @@ export class InfluxMetricsCollector extends MetricsCollector {
6689
try {
6790
this.writeApi.writePoints(points);
6891
await this.writeApi.flush();
69-
this.logger.debug(`Saved ${metricsBatch.length} metrics`);
7092
} catch (error) {
7193
this.logger.error(error, "Error saving metrics to InfluxDB");
7294
}
7395
}
7496

97+
private async executeInfluxQLQuery(
98+
query: string,
99+
): Promise<InfluxQLResponse | null> {
100+
const { url, username, password } = this.config.influx;
101+
const database = this.config.influx.bucket;
102+
103+
const queryUrl = `${url}/query?db=${database}&q=${encodeURIComponent(query)}`;
104+
105+
try {
106+
const response = await fetch(queryUrl, {
107+
method: "GET",
108+
headers: {
109+
Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`,
110+
"Content-Type": "application/json",
111+
},
112+
});
113+
114+
const result = await response.json();
115+
116+
if (!response.ok) {
117+
throw new Error(
118+
`InfluxDB query failed: ${response.status} ${response.statusText}`,
119+
);
120+
}
121+
122+
if (result.error) {
123+
throw new Error(`InfluxQL error: ${result.error}`);
124+
}
125+
126+
return result.results?.[0] || null;
127+
} catch (error) {
128+
this.logger.error(error, `InfluxQL query failed: ${query}`);
129+
return null;
130+
}
131+
}
132+
133+
public async getAvgGasPrice(): Promise<number | null> {
134+
try {
135+
const MIN_TXS = 5;
136+
137+
const query = `SELECT MEAN(gasPrice), COUNT(gasPrice) FROM tx_firewall_metrics WHERE time >= now() - 30d AND gasPrice > 0`;
138+
139+
const response = await this.executeInfluxQLQuery(query);
140+
141+
if (!response || !response.series || response.series.length === 0) {
142+
return null;
143+
}
144+
145+
const series = response.series[0];
146+
if (!series.values || series.values.length === 0) {
147+
return null;
148+
}
149+
150+
const data = series.values[0];
151+
const avgGasPrice = data[1] as number;
152+
const count = data[2] as number;
153+
154+
if (!avgGasPrice || count < MIN_TXS) {
155+
return null;
156+
}
157+
158+
return avgGasPrice;
159+
} catch (error) {
160+
this.logger.error(
161+
error,
162+
"Error fetching average gas price from InfluxDB",
163+
);
164+
return null;
165+
}
166+
}
167+
168+
public async getAvgFeePerGas(): Promise<number | null> {
169+
try {
170+
const MIN_TXS = 5;
171+
172+
const query = `SELECT MEAN(maxFeePerGas), COUNT(maxFeePerGas) FROM tx_firewall_metrics WHERE time >= now() - 30d AND maxFeePerGas > 0`;
173+
174+
const response = await this.executeInfluxQLQuery(query);
175+
176+
if (!response || !response.series || response.series.length === 0) {
177+
return null;
178+
}
179+
180+
const series = response.series[0];
181+
if (!series.values || series.values.length === 0) {
182+
return null;
183+
}
184+
185+
const data = series.values[0];
186+
const avgFeePerGas = data[1] as number;
187+
const count = data[2] as number;
188+
189+
if (!avgFeePerGas || count < MIN_TXS) {
190+
return null;
191+
}
192+
193+
return avgFeePerGas;
194+
} catch (error) {
195+
this.logger.error(
196+
error,
197+
"Error fetching average fee per gas from InfluxDB",
198+
);
199+
return null;
200+
}
201+
}
202+
75203
public close() {
76204
super.close();
77205
this.writeApi.close().catch((error: Error) => {

src/metrics/metrics.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export abstract class MetricsCollector {
3838
}
3939

4040
protected abstract save(metricsBatch: Metrics[]): Promise<void>;
41+
public abstract getAvgGasPrice(): Promise<number | null>;
42+
public abstract getAvgFeePerGas(): Promise<number | null>;
4143

4244
private initPeriodicSave() {
4345
this.saveIntervalId = setInterval(() => {
@@ -85,6 +87,54 @@ export class InMemoryMetricsCollector extends MetricsCollector {
8587
this.logger.debug(`Saved ${metricsBatch.length} metrics`);
8688
}
8789

90+
public async getAvgGasPrice(): Promise<number | null> {
91+
const MIN_TXS_WITH_GAS_PRICE = 5;
92+
const MAX_TXS_WITH_GAS_PRICE = 30;
93+
94+
const filteredTxs = this.getLegacyTransactions(MAX_TXS_WITH_GAS_PRICE);
95+
96+
if (filteredTxs.length < MIN_TXS_WITH_GAS_PRICE) {
97+
return null;
98+
}
99+
100+
const sum = filteredTxs.reduce((acc, m) => {
101+
return acc + Number(m.tx!.gasPrice!);
102+
}, 0);
103+
104+
return Promise.resolve(sum / filteredTxs.length);
105+
}
106+
107+
public async getAvgFeePerGas(): Promise<number | null> {
108+
const MIN_TXS_WITH_GAS_PRICE = 5;
109+
const MAX_TXS_WITH_GAS_PRICE = 30;
110+
111+
const filteredTxs = this.getEip1559Transactions(MAX_TXS_WITH_GAS_PRICE);
112+
113+
if (filteredTxs.length < MIN_TXS_WITH_GAS_PRICE) {
114+
return null;
115+
}
116+
117+
const sum = filteredTxs.reduce((acc, m) => {
118+
return acc + Number(m.tx!.maxFeePerGas!);
119+
}, 0);
120+
121+
return Promise.resolve(sum / filteredTxs.length);
122+
}
123+
124+
private getLegacyTransactions(maxCount: number) {
125+
return this.metrics
126+
.filter((m) => m.tx?.gasPrice !== undefined)
127+
.slice(-maxCount);
128+
}
129+
130+
private getEip1559Transactions(maxCount: number) {
131+
return this.metrics
132+
.filter(
133+
(m) => m.tx?.maxFeePerGas !== undefined && m.tx?.gasPrice === undefined,
134+
)
135+
.slice(-maxCount);
136+
}
137+
88138
private printSummary() {
89139
const today = new Date().toISOString().split("T")[0];
90140
const accepted = this.metrics.filter(
@@ -102,6 +152,8 @@ export class InMemoryMetricsCollector extends MetricsCollector {
102152
rejected,
103153
forwarded,
104154
all: this.metrics.length,
155+
avgGasPrice: this.getAvgGasPrice(),
156+
avgFeePerGas: this.getAvgFeePerGas(),
105157
},
106158
`Transaction processing daily summary`,
107159
);

src/proxy/proxy.spec.ts

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,20 @@ import {
88
} from "../transactions/transaction.js";
99
import { Logger } from "../utils/logger.js";
1010
import nock from "nock";
11-
import { MetricsCollector } from "../metrics/metrics.js";
11+
import {
12+
InMemoryMetricsCollector,
13+
MetricsCollector,
14+
} from "../metrics/metrics.js";
1215
import { WebsocketTransactionValidator } from "./websocket-validator.js";
1316

1417
describe("Proxy", () => {
1518
const transactionValidatorMock = mock(WebsocketTransactionValidator);
1619
const transactionBuilderMock = mock(TransactionBuilder);
1720
const transactionMock = mock(WrappedTransaction);
18-
const metricsCollectorMock = mock(MetricsCollector);
21+
const metricsCollectorMock = mock<MetricsCollector>(InMemoryMetricsCollector);
1922
when(transactionMock.dto).thenReturn({} as TransactionPayload);
23+
when(metricsCollectorMock.getAvgGasPrice()).thenResolve(null);
24+
when(metricsCollectorMock.getAvgFeePerGas()).thenResolve(null);
2025
const configMock = {
2126
proxyPort: 18555,
2227
endpointUrl: "http://localhost:8545/",
@@ -85,9 +90,13 @@ describe("Proxy", () => {
8590
});
8691

8792
test("should get a response with error for call with proper transaction and incorrect validation", async () => {
88-
when(transactionBuilderMock.fromJsonRpcRequest(anything())).thenReturn(
89-
transactionMock,
90-
);
93+
when(
94+
transactionBuilderMock.fromJsonRpcRequest(
95+
anything(),
96+
anything(),
97+
anything(),
98+
),
99+
).thenReturn(transactionMock);
91100
when(transactionValidatorMock.validate(anything())).thenReject(
92101
new ValidationError("Rejected by user", transactionMock.dto),
93102
);
@@ -105,9 +114,13 @@ describe("Proxy", () => {
105114
});
106115

107116
test("should get a response with error for call with invalid transaction", async () => {
108-
when(transactionBuilderMock.fromJsonRpcRequest(anything())).thenThrow(
109-
new Error("Invalid transaction decoding"),
110-
);
117+
when(
118+
transactionBuilderMock.fromJsonRpcRequest(
119+
anything(),
120+
anything(),
121+
anything(),
122+
),
123+
).thenThrow(new Error("Invalid transaction decoding"));
111124
when(transactionValidatorMock.validate(anything())).thenResolve(true);
112125
const response = await rpcRequest();
113126
expect(response.status).toBe(200);
@@ -128,9 +141,13 @@ describe("Proxy", () => {
128141
});
129142

130143
test("should process batch of rpc requests", async () => {
131-
when(transactionBuilderMock.fromJsonRpcRequest(anything())).thenReturn(
132-
transactionMock,
133-
);
144+
when(
145+
transactionBuilderMock.fromJsonRpcRequest(
146+
anything(),
147+
anything(),
148+
anything(),
149+
),
150+
).thenReturn(transactionMock);
134151
when(transactionValidatorMock.validate(anything())).thenResolve(true);
135152
const response = await rpcRequest("POST", [
136153
{ jsonrpc: "2.0", id: 1 },

0 commit comments

Comments
 (0)