Skip to content

Commit 8580d03

Browse files
authored
Add Miles leaderboard (#1787)
* Stub out /leaderboard route * Wire up points leaderboard * Add rank column * Update subtitle" * Remove feature flag
1 parent 0ca71b3 commit 8580d03

File tree

10 files changed

+404
-76
lines changed

10 files changed

+404
-76
lines changed

apps/hyperdrive-trading/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"@delvtech/drift": "^0.0.1-beta.11",
3333
"@delvtech/drift-viem": "^0.0.1-beta.13",
3434
"@delvtech/fixed-point-wasm": "^0.0.7",
35+
"addreth": "3.0.1",
3536
"@delvtech/hyperdrive-appconfig": "^0.1.0",
3637
"@delvtech/hyperdrive-js": "^0.0.1",
3738
"@headlessui/react": "^2.1.5",
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { MerklApi } from "@merkl/api";
2+
import groupBy from "lodash.groupby";
3+
import { ClaimableReward } from "src/rewards/ClaimableReward";
4+
import { Address, Hash } from "viem";
5+
import { gnosis } from "viem/chains";
6+
7+
/**
8+
* Merkl.xyz API client
9+
*/
10+
export const merklApi = MerklApi("https://api.merkl.xyz").v4;
11+
12+
/**
13+
* Returns the list of users with Miles and their balances.
14+
*/
15+
export interface LeaderboardEntry {
16+
address: Address;
17+
balance: bigint;
18+
rank: number;
19+
}
20+
21+
export async function fetchMilesLeaderboard(): Promise<LeaderboardEntry[]> {
22+
const opportunitiesResponse = await fetch(
23+
"https://api.merkl.xyz/v4/opportunities/campaigns?tokenAddress=0x79385D4B4c531bBbDa25C4cFB749781Bd9E23039",
24+
);
25+
const opportunities = (await opportunitiesResponse.json()) as {
26+
campaigns: { campaignId: string }[];
27+
}[];
28+
const campaignIds = Array.from(
29+
new Set(
30+
opportunities.flatMap((opportunity) =>
31+
opportunity.campaigns.map((campaign) => campaign.campaignId),
32+
),
33+
),
34+
);
35+
36+
const users = (
37+
await Promise.all(
38+
campaignIds.map(async (campaignId) => {
39+
const rewardsResponse = await fetch(
40+
`https://api.merkl.xyz/v4/rewards/?chainId=100&campaignId=${campaignId}`,
41+
);
42+
const rewards = (await rewardsResponse.json()) as {
43+
recipient: Address;
44+
amount: string;
45+
}[];
46+
return rewards;
47+
}),
48+
)
49+
).flat();
50+
51+
const rewardsByUser = Object.entries(groupBy(users, (user) => user.recipient))
52+
.map(([user, rewards]) => {
53+
const totalRewards = rewards.reduce(
54+
(total, reward) => total + BigInt(reward.amount),
55+
0n,
56+
);
57+
return { user, totalRewards };
58+
})
59+
.filter(({ totalRewards }) => totalRewards > 0n)
60+
.map(({ user, totalRewards }) => ({
61+
address: user as Address,
62+
balance: totalRewards,
63+
}))
64+
.toSorted((a, b) => Number(b.balance - a.balance))
65+
.map((entry, i) => ({ ...entry, rank: i + 1 })) as LeaderboardEntry[];
66+
67+
return rewardsByUser;
68+
}
69+
70+
/**
71+
* Merkl Distributor is the contract that you can claim rewards from in the
72+
* Merkl.xyz ecosystem.
73+
* See: https://app.merkl.xyz/status
74+
*/
75+
export const MerklDistributorsByChain: Record<number, Address> = {
76+
[gnosis.id]: "0x3Ef3D8bA38EBe18DB133cEc108f4D14CE00Dd9Ae",
77+
};
78+
79+
/**
80+
* Fetches the number of Miles a user has earned
81+
*/
82+
export async function fetchMileRewards(
83+
account: Address,
84+
): Promise<ClaimableReward[]> {
85+
// Merkl.xyz accumulates Miles across all chains and hyperdrives onto Gnosis
86+
// chain only. This makes things easier for turning them into HD later if
87+
// they're all just on one chain.
88+
const chainIds = [gnosis.id];
89+
90+
// Request miles earned on each chain. We have to call this once per chain
91+
// since the merkl api is buggy, despite accepting an array of chain ids. If
92+
// this gets fixed, we can remove the Promise.all and simplify this logic.
93+
const mileRewards = (
94+
await Promise.all(
95+
chainIds.map(async (chainId) => {
96+
const { data } = await merklApi
97+
.users({
98+
address: account,
99+
})
100+
.rewards.get({
101+
query: {
102+
chainId: [chainId],
103+
},
104+
});
105+
106+
return { data, chainId };
107+
}),
108+
)
109+
)
110+
.filter(
111+
({ data }) =>
112+
data?.length &&
113+
// since we only request a single chain id, we can just grab the first
114+
// data item
115+
data[0].rewards.find(
116+
// Merkl.xyz has something called HYPOINTS too, but we only care about
117+
// Miles
118+
(d) => d.token.symbol === "Miles" && !!Number(d.amount),
119+
),
120+
)
121+
.map(({ data, chainId }): ClaimableReward => {
122+
const rewards = data![0].rewards.find(
123+
(d) => d.token.symbol === "Miles" && !!Number(d.amount),
124+
);
125+
return {
126+
chainId,
127+
merkleType: "MerklXyz",
128+
merkleProof: rewards?.proofs as Hash[],
129+
claimableAmount: rewards?.amount.toString() || "0",
130+
pendingAmount: rewards?.pending.toString() || "0",
131+
merkleProofLastUpdated: 0,
132+
rewardTokenAddress: rewards?.token.address as `0x${string}`,
133+
// TODO: This won't use the same abi as the hyperdrive rewards api, so
134+
// we'll need to account for this somehow
135+
claimContractAddress: MerklDistributorsByChain[chainId],
136+
};
137+
})
138+
.flat();
139+
return mileRewards;
140+
}

apps/hyperdrive-trading/src/routeTree.gen.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { Route as ChainlogImport } from "./ui/routes/chainlog";
1515
import { Route as ErrorImport } from "./ui/routes/error";
1616
import { Route as IndexImport } from "./ui/routes/index";
1717
import { Route as IneligibleImport } from "./ui/routes/ineligible";
18+
import { Route as LeaderboardImport } from "./ui/routes/leaderboard";
1819
import { Route as MarketChainIdAddressImport } from "./ui/routes/market.$chainId.$address";
1920
import { Route as MintImport } from "./ui/routes/mint";
2021
import { Route as PointsmarketsImport } from "./ui/routes/points_markets";
@@ -47,6 +48,12 @@ const MintRoute = MintImport.update({
4748
getParentRoute: () => rootRoute,
4849
} as any);
4950

51+
const LeaderboardRoute = LeaderboardImport.update({
52+
id: "/leaderboard",
53+
path: "/leaderboard",
54+
getParentRoute: () => rootRoute,
55+
} as any);
56+
5057
const IneligibleRoute = IneligibleImport.update({
5158
id: "/ineligible",
5259
path: "/ineligible",
@@ -109,6 +116,13 @@ declare module "@tanstack/react-router" {
109116
preLoaderRoute: typeof IneligibleImport;
110117
parentRoute: typeof rootRoute;
111118
};
119+
"/leaderboard": {
120+
id: "/leaderboard";
121+
path: "/leaderboard";
122+
fullPath: "/leaderboard";
123+
preLoaderRoute: typeof LeaderboardImport;
124+
parentRoute: typeof rootRoute;
125+
};
112126
"/mint": {
113127
id: "/mint";
114128
path: "/mint";
@@ -154,6 +168,7 @@ export interface FileRoutesByFullPath {
154168
"/chainlog": typeof ChainlogRoute;
155169
"/error": typeof ErrorRoute;
156170
"/ineligible": typeof IneligibleRoute;
171+
"/leaderboard": typeof LeaderboardRoute;
157172
"/mint": typeof MintRoute;
158173
"/points_markets": typeof PointsmarketsRoute;
159174
"/portfolio": typeof PortfolioRoute;
@@ -166,6 +181,7 @@ export interface FileRoutesByTo {
166181
"/chainlog": typeof ChainlogRoute;
167182
"/error": typeof ErrorRoute;
168183
"/ineligible": typeof IneligibleRoute;
184+
"/leaderboard": typeof LeaderboardRoute;
169185
"/mint": typeof MintRoute;
170186
"/points_markets": typeof PointsmarketsRoute;
171187
"/portfolio": typeof PortfolioRoute;
@@ -179,6 +195,7 @@ export interface FileRoutesById {
179195
"/chainlog": typeof ChainlogRoute;
180196
"/error": typeof ErrorRoute;
181197
"/ineligible": typeof IneligibleRoute;
198+
"/leaderboard": typeof LeaderboardRoute;
182199
"/mint": typeof MintRoute;
183200
"/points_markets": typeof PointsmarketsRoute;
184201
"/portfolio": typeof PortfolioRoute;
@@ -193,6 +210,7 @@ export interface FileRouteTypes {
193210
| "/chainlog"
194211
| "/error"
195212
| "/ineligible"
213+
| "/leaderboard"
196214
| "/mint"
197215
| "/points_markets"
198216
| "/portfolio"
@@ -204,6 +222,7 @@ export interface FileRouteTypes {
204222
| "/chainlog"
205223
| "/error"
206224
| "/ineligible"
225+
| "/leaderboard"
207226
| "/mint"
208227
| "/points_markets"
209228
| "/portfolio"
@@ -215,6 +234,7 @@ export interface FileRouteTypes {
215234
| "/chainlog"
216235
| "/error"
217236
| "/ineligible"
237+
| "/leaderboard"
218238
| "/mint"
219239
| "/points_markets"
220240
| "/portfolio"
@@ -228,6 +248,7 @@ export interface RootRouteChildren {
228248
ChainlogRoute: typeof ChainlogRoute;
229249
ErrorRoute: typeof ErrorRoute;
230250
IneligibleRoute: typeof IneligibleRoute;
251+
LeaderboardRoute: typeof LeaderboardRoute;
231252
MintRoute: typeof MintRoute;
232253
PointsmarketsRoute: typeof PointsmarketsRoute;
233254
PortfolioRoute: typeof PortfolioRoute;
@@ -240,6 +261,7 @@ const rootRouteChildren: RootRouteChildren = {
240261
ChainlogRoute: ChainlogRoute,
241262
ErrorRoute: ErrorRoute,
242263
IneligibleRoute: IneligibleRoute,
264+
LeaderboardRoute: LeaderboardRoute,
243265
MintRoute: MintRoute,
244266
PointsmarketsRoute: PointsmarketsRoute,
245267
PortfolioRoute: PortfolioRoute,
@@ -261,6 +283,7 @@ export const routeTree = rootRoute
261283
"/chainlog",
262284
"/error",
263285
"/ineligible",
286+
"/leaderboard",
264287
"/mint",
265288
"/points_markets",
266289
"/portfolio",
@@ -280,6 +303,9 @@ export const routeTree = rootRoute
280303
"/ineligible": {
281304
"filePath": "ineligible.tsx"
282305
},
306+
"/leaderboard": {
307+
"filePath": "leaderboard.tsx"
308+
},
283309
"/mint": {
284310
"filePath": "mint.tsx"
285311
},

apps/hyperdrive-trading/src/ui/app/Navbar/DevtoolsMenu.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ export function DevtoolsMenu(): ReactElement {
1717
Menu Item Name here
1818
</FeatureFlagMenuItem> */}
1919
<FeatureFlagMenuItem flagName="zaps">Zaps</FeatureFlagMenuItem>
20+
<FeatureFlagMenuItem flagName="miles-leaderboard">
21+
Leaderboard
22+
</FeatureFlagMenuItem>
2023
<FeatureFlagMenuItem flagName="portfolio-rewards">
2124
Portfolio Rewards
2225
</FeatureFlagMenuItem>

apps/hyperdrive-trading/src/ui/app/Navbar/Navbar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { LANDING_ROUTE } from "src/ui/landing/routes";
1717
import { POINTS_MARKETS_ROUTE } from "src/ui/markets/routes";
1818
import { MINT_ROUTE } from "src/ui/mint/routes";
1919
import { PORTFOLIO_ROUTE } from "src/ui/portfolio/routes";
20+
import { POINTS_LEADERBOARD_ROUTE } from "src/ui/rewards/routes";
2021
import { sepolia } from "viem/chains";
2122
import { useChainId } from "wagmi";
2223

@@ -39,6 +40,7 @@ export function Navbar(): ReactElement {
3940
<NavbarLink to={LANDING_ROUTE} label="All Pools" />
4041
<NavbarLink to={POINTS_MARKETS_ROUTE} label="Points Markets" />
4142
<NavbarLink to={PORTFOLIO_ROUTE} label="Portfolio" />
43+
<NavbarLink to={POINTS_LEADERBOARD_ROUTE} label="Leaderboard" />
4244
{isTestnet ? (
4345
<NavbarLink to={MINT_ROUTE} label="Mint Tokens" />
4446
) : null}

0 commit comments

Comments
 (0)