Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/hyperdrive-trading/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@delvtech/drift": "^0.0.1-beta.11",
"@delvtech/drift-viem": "^0.0.1-beta.13",
"@delvtech/fixed-point-wasm": "^0.0.7",
"addreth": "3.0.1",
"@delvtech/hyperdrive-appconfig": "^0.1.0",
"@delvtech/hyperdrive-js": "^0.0.1",
"@headlessui/react": "^2.1.5",
Expand Down
140 changes: 140 additions & 0 deletions apps/hyperdrive-trading/src/rewards/merkl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { MerklApi } from "@merkl/api";
import groupBy from "lodash.groupby";
import { ClaimableReward } from "src/rewards/ClaimableReward";
import { Address, Hash } from "viem";
import { gnosis } from "viem/chains";

/**
* Merkl.xyz API client
*/
export const merklApi = MerklApi("https://api.merkl.xyz").v4;

/**
* Returns the list of users with Miles and their balances.
*/
export interface LeaderboardEntry {
address: Address;
balance: bigint;
rank: number;
}

export async function fetchMilesLeaderboard(): Promise<LeaderboardEntry[]> {
const opportunitiesResponse = await fetch(
"https://api.merkl.xyz/v4/opportunities/campaigns?tokenAddress=0x79385D4B4c531bBbDa25C4cFB749781Bd9E23039",
);
const opportunities = (await opportunitiesResponse.json()) as {
campaigns: { campaignId: string }[];
}[];
const campaignIds = Array.from(
new Set(
opportunities.flatMap((opportunity) =>
opportunity.campaigns.map((campaign) => campaign.campaignId),
),
),
);

const users = (
await Promise.all(
campaignIds.map(async (campaignId) => {
const rewardsResponse = await fetch(
`https://api.merkl.xyz/v4/rewards/?chainId=100&campaignId=${campaignId}`,
);
const rewards = (await rewardsResponse.json()) as {
recipient: Address;
amount: string;
}[];
return rewards;
}),
)
).flat();

const rewardsByUser = Object.entries(groupBy(users, (user) => user.recipient))
.map(([user, rewards]) => {
const totalRewards = rewards.reduce(
(total, reward) => total + BigInt(reward.amount),
0n,
);
return { user, totalRewards };
})
.filter(({ totalRewards }) => totalRewards > 0n)
.map(({ user, totalRewards }) => ({
address: user as Address,
balance: totalRewards,
}))
.toSorted((a, b) => Number(b.balance - a.balance))
.map((entry, i) => ({ ...entry, rank: i + 1 })) as LeaderboardEntry[];

return rewardsByUser;
}

/**
* Merkl Distributor is the contract that you can claim rewards from in the
* Merkl.xyz ecosystem.
* See: https://app.merkl.xyz/status
*/
export const MerklDistributorsByChain: Record<number, Address> = {
[gnosis.id]: "0x3Ef3D8bA38EBe18DB133cEc108f4D14CE00Dd9Ae",
};

/**
* Fetches the number of Miles a user has earned
*/
export async function fetchMileRewards(
account: Address,
): Promise<ClaimableReward[]> {
// Merkl.xyz accumulates Miles across all chains and hyperdrives onto Gnosis
// chain only. This makes things easier for turning them into HD later if
// they're all just on one chain.
const chainIds = [gnosis.id];

// Request miles earned on each chain. We have to call this once per chain
// since the merkl api is buggy, despite accepting an array of chain ids. If
// this gets fixed, we can remove the Promise.all and simplify this logic.
const mileRewards = (
await Promise.all(
chainIds.map(async (chainId) => {
const { data } = await merklApi
.users({
address: account,
})
.rewards.get({
query: {
chainId: [chainId],
},
});

return { data, chainId };
}),
)
)
.filter(
({ data }) =>
data?.length &&
// since we only request a single chain id, we can just grab the first
// data item
data[0].rewards.find(
// Merkl.xyz has something called HYPOINTS too, but we only care about
// Miles
(d) => d.token.symbol === "Miles" && !!Number(d.amount),
),
)
.map(({ data, chainId }): ClaimableReward => {
const rewards = data![0].rewards.find(
(d) => d.token.symbol === "Miles" && !!Number(d.amount),
);
return {
chainId,
merkleType: "MerklXyz",
merkleProof: rewards?.proofs as Hash[],
claimableAmount: rewards?.amount.toString() || "0",
pendingAmount: rewards?.pending.toString() || "0",
merkleProofLastUpdated: 0,
rewardTokenAddress: rewards?.token.address as `0x${string}`,
// TODO: This won't use the same abi as the hyperdrive rewards api, so
// we'll need to account for this somehow
claimContractAddress: MerklDistributorsByChain[chainId],
};
})
.flat();
return mileRewards;
}
26 changes: 26 additions & 0 deletions apps/hyperdrive-trading/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Route as ChainlogImport } from "./ui/routes/chainlog";
import { Route as ErrorImport } from "./ui/routes/error";
import { Route as IndexImport } from "./ui/routes/index";
import { Route as IneligibleImport } from "./ui/routes/ineligible";
import { Route as LeaderboardImport } from "./ui/routes/leaderboard";
import { Route as MarketChainIdAddressImport } from "./ui/routes/market.$chainId.$address";
import { Route as MintImport } from "./ui/routes/mint";
import { Route as PointsmarketsImport } from "./ui/routes/points_markets";
Expand Down Expand Up @@ -47,6 +48,12 @@ const MintRoute = MintImport.update({
getParentRoute: () => rootRoute,
} as any);

const LeaderboardRoute = LeaderboardImport.update({
id: "/leaderboard",
path: "/leaderboard",
getParentRoute: () => rootRoute,
} as any);

const IneligibleRoute = IneligibleImport.update({
id: "/ineligible",
path: "/ineligible",
Expand Down Expand Up @@ -109,6 +116,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof IneligibleImport;
parentRoute: typeof rootRoute;
};
"/leaderboard": {
id: "/leaderboard";
path: "/leaderboard";
fullPath: "/leaderboard";
preLoaderRoute: typeof LeaderboardImport;
parentRoute: typeof rootRoute;
};
"/mint": {
id: "/mint";
path: "/mint";
Expand Down Expand Up @@ -154,6 +168,7 @@ export interface FileRoutesByFullPath {
"/chainlog": typeof ChainlogRoute;
"/error": typeof ErrorRoute;
"/ineligible": typeof IneligibleRoute;
"/leaderboard": typeof LeaderboardRoute;
"/mint": typeof MintRoute;
"/points_markets": typeof PointsmarketsRoute;
"/portfolio": typeof PortfolioRoute;
Expand All @@ -166,6 +181,7 @@ export interface FileRoutesByTo {
"/chainlog": typeof ChainlogRoute;
"/error": typeof ErrorRoute;
"/ineligible": typeof IneligibleRoute;
"/leaderboard": typeof LeaderboardRoute;
"/mint": typeof MintRoute;
"/points_markets": typeof PointsmarketsRoute;
"/portfolio": typeof PortfolioRoute;
Expand All @@ -179,6 +195,7 @@ export interface FileRoutesById {
"/chainlog": typeof ChainlogRoute;
"/error": typeof ErrorRoute;
"/ineligible": typeof IneligibleRoute;
"/leaderboard": typeof LeaderboardRoute;
"/mint": typeof MintRoute;
"/points_markets": typeof PointsmarketsRoute;
"/portfolio": typeof PortfolioRoute;
Expand All @@ -193,6 +210,7 @@ export interface FileRouteTypes {
| "/chainlog"
| "/error"
| "/ineligible"
| "/leaderboard"
| "/mint"
| "/points_markets"
| "/portfolio"
Expand All @@ -204,6 +222,7 @@ export interface FileRouteTypes {
| "/chainlog"
| "/error"
| "/ineligible"
| "/leaderboard"
| "/mint"
| "/points_markets"
| "/portfolio"
Expand All @@ -215,6 +234,7 @@ export interface FileRouteTypes {
| "/chainlog"
| "/error"
| "/ineligible"
| "/leaderboard"
| "/mint"
| "/points_markets"
| "/portfolio"
Expand All @@ -228,6 +248,7 @@ export interface RootRouteChildren {
ChainlogRoute: typeof ChainlogRoute;
ErrorRoute: typeof ErrorRoute;
IneligibleRoute: typeof IneligibleRoute;
LeaderboardRoute: typeof LeaderboardRoute;
MintRoute: typeof MintRoute;
PointsmarketsRoute: typeof PointsmarketsRoute;
PortfolioRoute: typeof PortfolioRoute;
Expand All @@ -240,6 +261,7 @@ const rootRouteChildren: RootRouteChildren = {
ChainlogRoute: ChainlogRoute,
ErrorRoute: ErrorRoute,
IneligibleRoute: IneligibleRoute,
LeaderboardRoute: LeaderboardRoute,
MintRoute: MintRoute,
PointsmarketsRoute: PointsmarketsRoute,
PortfolioRoute: PortfolioRoute,
Expand All @@ -261,6 +283,7 @@ export const routeTree = rootRoute
"/chainlog",
"/error",
"/ineligible",
"/leaderboard",
"/mint",
"/points_markets",
"/portfolio",
Expand All @@ -280,6 +303,9 @@ export const routeTree = rootRoute
"/ineligible": {
"filePath": "ineligible.tsx"
},
"/leaderboard": {
"filePath": "leaderboard.tsx"
},
"/mint": {
"filePath": "mint.tsx"
},
Expand Down
3 changes: 3 additions & 0 deletions apps/hyperdrive-trading/src/ui/app/Navbar/DevtoolsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ export function DevtoolsMenu(): ReactElement {
Menu Item Name here
</FeatureFlagMenuItem> */}
<FeatureFlagMenuItem flagName="zaps">Zaps</FeatureFlagMenuItem>
<FeatureFlagMenuItem flagName="miles-leaderboard">
Leaderboard
</FeatureFlagMenuItem>
<FeatureFlagMenuItem flagName="portfolio-rewards">
Portfolio Rewards
</FeatureFlagMenuItem>
Expand Down
2 changes: 2 additions & 0 deletions apps/hyperdrive-trading/src/ui/app/Navbar/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import { POINTS_MARKETS_ROUTE } from "src/ui/markets/routes";
import { MINT_ROUTE } from "src/ui/mint/routes";
import { PORTFOLIO_ROUTE } from "src/ui/portfolio/routes";
import { POINTS_LEADERBOARD_ROUTE } from "src/ui/rewards/routes";
import { sepolia } from "viem/chains";
import { useChainId } from "wagmi";

Expand All @@ -39,6 +40,7 @@
<NavbarLink to={LANDING_ROUTE} label="All Pools" />
<NavbarLink to={POINTS_MARKETS_ROUTE} label="Points Markets" />
<NavbarLink to={PORTFOLIO_ROUTE} label="Portfolio" />
<NavbarLink to={POINTS_LEADERBOARD_ROUTE} label="Leaderboard" />
{isTestnet ? (
<NavbarLink to={MINT_ROUTE} label="Mint Tokens" />
) : null}
Expand Down Expand Up @@ -76,7 +78,7 @@
to={
// safe to cast because LinkProps["to"] provides typesafety to the
// caller
to as any

Check warning on line 81 in apps/hyperdrive-trading/src/ui/app/Navbar/Navbar.tsx

View workflow job for this annotation

GitHub Actions / verify (lint)

Unexpected any. Specify a different type
}
className="hidden sm:inline"
>
Expand Down
Loading
Loading