Skip to content

Commit a93ef8f

Browse files
committed
Add gas metrics chart to account abstraction analytics
Introduces a new GasMetricsChartCard component to visualize gas usage, average gas price, and cost efficiency across chains. Updates the analytics API and UserOpStats type to include gasUnits and avgGasPrice fields, and aggregates these metrics for display. Integrates the new chart into the account abstraction analytics page. Closes BLD-460
1 parent b0e69b8 commit a93ef8f

File tree

4 files changed

+314
-2
lines changed

4 files changed

+314
-2
lines changed

apps/dashboard/src/@/api/analytics.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,8 +291,11 @@ const cached_getAggregateUserOpUsage = unstable_cache(
291291
]);
292292

293293
// Aggregate stats across wallet types
294-
return userOpStats.reduce(
295-
(acc, curr) => {
294+
const aggregated = userOpStats.reduce(
295+
(
296+
acc: UserOpStats & { gasPriceSum: number; gasPriceCount: number },
297+
curr,
298+
) => {
296299
// Skip testnets from the aggregated stats
297300
if (curr.chainId) {
298301
const chain = chains.data.find(
@@ -306,15 +309,39 @@ const cached_getAggregateUserOpUsage = unstable_cache(
306309
acc.successful += curr.successful;
307310
acc.failed += curr.failed;
308311
acc.sponsoredUsd += curr.sponsoredUsd;
312+
acc.gasUnits += curr.gasUnits;
313+
// For avgGasPrice, we'll track sum and count for proper averaging
314+
acc.gasPriceSum += curr.avgGasPrice * curr.successful;
315+
acc.gasPriceCount += curr.successful;
309316
return acc;
310317
},
311318
{
312319
date: (params.from || new Date()).toISOString(),
313320
failed: 0,
314321
sponsoredUsd: 0,
315322
successful: 0,
323+
gasUnits: 0,
324+
avgGasPrice: 0,
325+
gasPriceSum: 0,
326+
gasPriceCount: 0,
316327
},
317328
);
329+
330+
// Calculate the proper average gas price
331+
aggregated.avgGasPrice =
332+
aggregated.gasPriceCount > 0
333+
? aggregated.gasPriceSum / aggregated.gasPriceCount
334+
: 0;
335+
336+
// Return only the UserOpStats fields
337+
return {
338+
date: aggregated.date,
339+
failed: aggregated.failed,
340+
sponsoredUsd: aggregated.sponsoredUsd,
341+
successful: aggregated.successful,
342+
gasUnits: aggregated.gasUnits,
343+
avgGasPrice: aggregated.avgGasPrice,
344+
};
318345
},
319346
["getAggregateUserOpUsage"],
320347
{

apps/dashboard/src/@/types/analytics.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export interface UserOpStats {
2323
successful: number;
2424
failed: number;
2525
sponsoredUsd: number;
26+
gasUnits: number;
27+
avgGasPrice: number;
2628
chainId?: string;
2729
}
2830

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
"use client";
2+
3+
import { format } from "date-fns";
4+
import { useMemo, useState } from "react";
5+
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
6+
import { DocLink } from "@/components/blocks/DocLink";
7+
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
8+
import { Button } from "@/components/ui/button";
9+
import type { ChartConfig } from "@/components/ui/chart";
10+
import { useAllChainsData } from "@/hooks/chains/allChains";
11+
import { DotNetIcon } from "@/icons/brand-icons/DotNetIcon";
12+
import { ReactIcon } from "@/icons/brand-icons/ReactIcon";
13+
import { TypeScriptIcon } from "@/icons/brand-icons/TypeScriptIcon";
14+
import { UnityIcon } from "@/icons/brand-icons/UnityIcon";
15+
import { UnrealIcon } from "@/icons/brand-icons/UnrealIcon";
16+
import type { UserOpStats } from "@/types/analytics";
17+
import { formatTickerNumber } from "@/utils/format-utils";
18+
19+
type ChartData = Record<string, number> & {
20+
time: string;
21+
};
22+
23+
type MetricType = "gasUnits" | "avgGasPrice" | "costPerGasUnit";
24+
25+
export function GasMetricsChartCard(props: {
26+
userOpStats: UserOpStats[];
27+
isPending: boolean;
28+
}) {
29+
const { userOpStats } = props;
30+
const topChainsToShow = 10;
31+
const chainsStore = useAllChainsData();
32+
const [metricType, setMetricType] = useState<MetricType>("gasUnits");
33+
34+
const { chartConfig, chartData } = useMemo(() => {
35+
const _chartConfig: ChartConfig = {};
36+
const _chartDataMap: Map<string, ChartData> = new Map();
37+
const chainIdToVolumeMap: Map<string, number> = new Map();
38+
39+
// for each stat, add it in _chartDataMap
40+
for (const stat of userOpStats) {
41+
const chartData = _chartDataMap.get(stat.date);
42+
const { chainId } = stat;
43+
const chain = chainsStore.idToChain.get(Number(chainId));
44+
const chainName = chain?.name || chainId || "Unknown";
45+
46+
let value: number;
47+
if (metricType === "gasUnits") {
48+
value = stat.gasUnits;
49+
} else if (metricType === "avgGasPrice") {
50+
value = stat.avgGasPrice;
51+
} else {
52+
// costPerGasUnit: USD spent per gas unit (helps identify price spike impact)
53+
value = stat.gasUnits > 0 ? stat.sponsoredUsd / stat.gasUnits : 0;
54+
}
55+
56+
// if no data for current day - create new entry
57+
if (!chartData) {
58+
_chartDataMap.set(stat.date, {
59+
time: stat.date,
60+
[chainName]: value,
61+
} as ChartData);
62+
} else {
63+
chartData[chainName] = (chartData[chainName] || 0) + value;
64+
}
65+
66+
chainIdToVolumeMap.set(
67+
chainName,
68+
value + (chainIdToVolumeMap.get(chainName) || 0),
69+
);
70+
}
71+
72+
const chainsSorted = Array.from(chainIdToVolumeMap.entries())
73+
.sort((a, b) => b[1] - a[1])
74+
.map((w) => w[0]);
75+
76+
const chainsToShow = chainsSorted.slice(0, topChainsToShow);
77+
const chainsToTagAsOthers = chainsSorted.slice(topChainsToShow);
78+
79+
// replace chainIdsToTagAsOther chainId with "other"
80+
for (const data of _chartDataMap.values()) {
81+
for (const chainId in data) {
82+
if (chainsToTagAsOthers.includes(chainId)) {
83+
data.others = (data.others || 0) + (data[chainId] || 0);
84+
delete data[chainId];
85+
}
86+
}
87+
}
88+
89+
chainsToShow.forEach((chainName, i) => {
90+
_chartConfig[chainName] = {
91+
color: `hsl(var(--chart-${(i % 10) + 1}))`,
92+
label: chainName,
93+
};
94+
});
95+
96+
// Add Other
97+
if (chainsToTagAsOthers.length > 0) {
98+
chainsToShow.push("others");
99+
_chartConfig.others = {
100+
color: "hsl(var(--muted-foreground))",
101+
label: "Others",
102+
};
103+
}
104+
105+
return {
106+
chartConfig: _chartConfig,
107+
chartData: Array.from(_chartDataMap.values()).sort((a, b) => {
108+
return new Date(a.time).getTime() - new Date(b.time).getTime();
109+
}),
110+
};
111+
}, [userOpStats, chainsStore, metricType]);
112+
113+
const uniqueChainIds = Object.keys(chartConfig);
114+
const disableActions =
115+
props.isPending ||
116+
chartData.length === 0 ||
117+
chartData.every((data) => {
118+
return Object.entries(data).every(([key, value]) => {
119+
return key === "time" || value === 0;
120+
});
121+
});
122+
123+
const metricConfig = {
124+
gasUnits: {
125+
title: "Gas Units Consumed",
126+
description: "Total gas units consumed by sponsored transactions",
127+
fileName: "Gas Units Consumed",
128+
formatter: (value: number) => formatTickerNumber(value),
129+
},
130+
avgGasPrice: {
131+
title: "Average Gas Price",
132+
description: "Average gas price in Gwei for sponsored transactions",
133+
fileName: "Average Gas Price",
134+
formatter: (value: number) => {
135+
// Convert from wei to Gwei
136+
const gwei = value / 1_000_000_000;
137+
return `${formatTickerNumber(gwei)} Gwei`;
138+
},
139+
},
140+
costPerGasUnit: {
141+
title: "Cost per Gas Unit",
142+
description:
143+
"USD spent per gas unit - spikes indicate gas price increases",
144+
fileName: "Cost per Gas Unit",
145+
formatter: (value: number) => {
146+
// Show in micro-USD for readability
147+
return `$${(value * 1_000_000).toFixed(4)}µ`;
148+
},
149+
},
150+
};
151+
152+
const config = metricConfig[metricType];
153+
154+
return (
155+
<ThirdwebBarChart
156+
chartClassName="aspect-[1] lg:aspect-[3.5]"
157+
config={chartConfig}
158+
customHeader={
159+
<div className="relative px-6 pt-6">
160+
<div className="flex items-start justify-between gap-4">
161+
<div>
162+
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
163+
{config.title}
164+
</h3>
165+
<p className="text-muted-foreground text-sm">
166+
{config.description}
167+
</p>
168+
</div>
169+
<div className="flex gap-2 flex-wrap">
170+
<Button
171+
onClick={() => setMetricType("gasUnits")}
172+
size="sm"
173+
variant={metricType === "gasUnits" ? "default" : "outline"}
174+
>
175+
Gas Units
176+
</Button>
177+
<Button
178+
onClick={() => setMetricType("avgGasPrice")}
179+
size="sm"
180+
variant={metricType === "avgGasPrice" ? "default" : "outline"}
181+
>
182+
Gas Price
183+
</Button>
184+
<Button
185+
onClick={() => setMetricType("costPerGasUnit")}
186+
size="sm"
187+
variant={
188+
metricType === "costPerGasUnit" ? "default" : "outline"
189+
}
190+
>
191+
Cost Efficiency
192+
</Button>
193+
</div>
194+
</div>
195+
196+
<div className="top-6 right-6 mt-4 mb-4 grid grid-cols-2 items-center gap-2 md:absolute md:my-0 md:flex md:mt-10">
197+
<ExportToCSVButton
198+
className="bg-background"
199+
disabled={disableActions}
200+
fileName={config.fileName}
201+
getData={async () => {
202+
const header = ["Date", ...uniqueChainIds];
203+
const rows = chartData.map((data) => {
204+
const { time, ...rest } = data;
205+
return [
206+
time,
207+
...uniqueChainIds.map((w) => (rest[w] || 0).toString()),
208+
];
209+
});
210+
return { header, rows };
211+
}}
212+
/>
213+
</div>
214+
</div>
215+
}
216+
data={chartData}
217+
emptyChartState={<GasMetricsChartCardEmptyChartState />}
218+
hideLabel={false}
219+
isPending={props.isPending}
220+
showLegend
221+
toolTipLabelFormatter={(_v, item) => {
222+
if (Array.isArray(item)) {
223+
const time = item[0].payload.time as number;
224+
return format(new Date(time), "MMM d, yyyy");
225+
}
226+
return undefined;
227+
}}
228+
toolTipValueFormatter={(value) => {
229+
if (typeof value !== "number") {
230+
return "";
231+
}
232+
return config.formatter(value);
233+
}}
234+
variant="stacked"
235+
/>
236+
);
237+
}
238+
239+
function GasMetricsChartCardEmptyChartState() {
240+
return (
241+
<div className="flex flex-col items-center justify-center">
242+
<span className="mb-6 text-lg">Sponsor gas for your users</span>
243+
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
244+
<DocLink
245+
icon={TypeScriptIcon}
246+
label="TypeScript"
247+
link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started"
248+
/>
249+
<DocLink
250+
icon={ReactIcon}
251+
label="React"
252+
link="https://portal.thirdweb.com/react/v5/account-abstraction/get-started"
253+
/>
254+
<DocLink
255+
icon={ReactIcon}
256+
label="React Native"
257+
link="https://portal.thirdweb.com/react/v5/account-abstraction/get-started"
258+
/>
259+
<DocLink
260+
icon={UnityIcon}
261+
label="Unity"
262+
link="https://portal.thirdweb.com/unity/v5/wallets/account-abstraction"
263+
/>
264+
<DocLink
265+
icon={UnrealIcon}
266+
label="Unreal Engine"
267+
link="https://portal.thirdweb.com/unreal-engine/blueprints/smart-wallet"
268+
/>
269+
<DocLink
270+
icon={DotNetIcon}
271+
label=".NET"
272+
link="https://portal.thirdweb.com/dotnet/wallets/providers/account-abstraction"
273+
/>
274+
</div>
275+
</div>
276+
);
277+
}

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/account-abstraction/aa-analytics.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import { IntervalSelector } from "@/components/analytics/interval-selector";
1212
import { SponsoredTransactionsTable } from "@/components/sponsored-transactions-table/SponsoredTransactionsTable";
1313
import type { UserOpStats } from "@/types/analytics";
14+
import { GasMetricsChartCard } from "./AccountAbstractionAnalytics/GasMetricsChartCard";
1415
import { SponsoredTransactionsChartCard } from "./AccountAbstractionAnalytics/SponsoredTransactionsChartCard";
1516
import { TotalSponsoredChartCard } from "./AccountAbstractionAnalytics/TotalSponsoredChartCard";
1617
import { searchParams } from "./search-params";
@@ -105,6 +106,11 @@ export function AccountAbstractionAnalytics(props: {
105106
userOpStats={props.userOpStats}
106107
/>
107108

109+
<GasMetricsChartCard
110+
isPending={isLoading}
111+
userOpStats={props.userOpStats}
112+
/>
113+
108114
<SponsoredTransactionsTable
109115
client={props.client}
110116
from={from.toISOString()}

0 commit comments

Comments
 (0)