Skip to content

Commit 82ba2b2

Browse files
authored
Add gas metrics chart to account abstraction analytics (#8442)
1 parent b0e69b8 commit 82ba2b2

File tree

5 files changed

+339
-4
lines changed

5 files changed

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

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/account-abstraction/AccountAbstractionAnalytics/storyUtils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,17 @@ export function createUserOpStatsStub(days: number): UserOpStats[] {
99
const successful = Math.random() * 100;
1010
const failed = Math.random() * 100;
1111
const sponsoredUsd = Math.random() * 100;
12+
const gasUnits = Math.random() * 1000000; // Random gas units between 0-1M
13+
const avgGasPrice = Math.random() * 100000000000; // Random gas price in wei (0-100 Gwei)
1214

1315
stubbedData.push({
1416
chainId: Math.floor(Math.random() * 100).toString(),
1517
date: new Date(2024, 11, d).toLocaleString(),
1618
failed,
1719
sponsoredUsd,
1820
successful,
21+
gasUnits,
22+
avgGasPrice,
1923
});
2024

2125
if (Math.random() > 0.7) {

0 commit comments

Comments
 (0)