@moonwell-fi/moonwell-sdk
Version:
TypeScript Interface for Moonwell
198 lines (173 loc) • 6 kB
text/typescript
import { isAddress } from "viem";
import { base, moonbeam, optimism } from "viem/chains";
import type { MoonwellClient } from "../../client/createMoonwellClient.js";
import {
type Environment,
publicEnvironments,
} from "../../environments/index.js";
import * as logger from "../../logger/console.js";
import type { Delegate } from "../../types/delegate.js";
import { getWithRetry } from "../axiosWithRetry.js";
import { fetchAllVoters } from "./governor-api-client.js";
export type GetDelegatesReturnType = Promise<Delegate[]>;
/**
* Returns a list of delegates with voting power and activity stats
* Data is fetched from Governor API, enriched with forum profiles and on-chain voting power
* All delegates are returned sorted by total voting power
* The app should handle pagination locally for better performance and caching
*/
export async function getDelegates(
client: MoonwellClient,
): GetDelegatesReturnType {
const logId = logger.start("getDelegates", "Starting to get delegates...");
const governanceEnvironment = publicEnvironments.moonbeam;
const apiVoters = await fetchAllVoters(governanceEnvironment);
const forumProfiles = await getForumProfiles();
// TODO: include mainnet.id once Ethereum's views contract ships, to match
// WELL.chainIds in environments/definitions/governance.ts.
const targetChainIds = [moonbeam.id, base.id, optimism.id] as const;
const envs = Object.values(client.environments as Environment[]).filter(
(env) =>
env.contracts.views !== undefined &&
(targetChainIds as readonly number[]).includes(env.chainId),
);
const votingPowers = await Promise.all(
apiVoters.map(async (voter) =>
Promise.all(
envs.map((environment) =>
environment.contracts.views?.read.getUserVotingPower([
voter.id as `0x${string}`,
]),
),
),
),
);
let delegates: Delegate[] = apiVoters.map((voter, index) => {
const forumProfile = forumProfiles.find(
(p) => p.wallet.toLowerCase() === voter.id.toLowerCase(),
);
const votingPower: { [chainId: string]: number } = {};
const userVotingPowers = votingPowers[index];
if (userVotingPowers) {
envs.forEach((env, reduceIndex) => {
const { claimsVotes, stakingVotes, tokenVotes } =
userVotingPowers[reduceIndex]!;
const totalVotes =
claimsVotes.delegatedVotingPower +
stakingVotes.delegatedVotingPower +
tokenVotes.delegatedVotingPower;
votingPower[env.chainId] = Number(totalVotes / BigInt(10 ** 18));
});
}
const delegate: Delegate = {
avatar: forumProfile?.avatar || "",
name: forumProfile?.name || voter.id,
wallet: voter.id as `0x${string}`,
pitch: forumProfile?.pitch || { intro: "", url: "" },
proposals: {
all: {
created: voter.createdProposalIds.length,
voted: voter.votedProposalIds.length,
},
},
votingPower,
};
return delegate;
});
delegates = delegates.sort((a, b) => {
const aTotalPower = Object.values(a.votingPower || {}).reduce(
(sum, power) => sum + power,
0,
);
const bTotalPower = Object.values(b.votingPower || {}).reduce(
(sum, power) => sum + power,
0,
);
return bTotalPower - aTotalPower;
});
logger.end(logId);
return delegates;
}
/**
* Helper function to fetch delegate profiles from forum
*/
const getForumProfiles = async () => {
const profiles: Array<{
avatar: string;
name: string;
wallet: string;
pitch?: {
intro: string;
url?: string;
};
}> = [];
const getUsersPaginated = async (page = 0) => {
try {
const response = await getWithRetry<{
directory_items: {
user: {
id: number;
username: string;
name: string;
avatar_template: string;
title: string;
user_fields: {
"1": { value: string[] };
"2": { value: string[] };
"3": { value: string[] };
};
wallet_address: string;
pitch_intro: string;
pitch_link: string;
};
}[];
meta: {
total_rows_directory_items: number;
load_more_directory_items: string;
};
}>(
`https://forum.moonwell.fi/directory_items.json?period=all&order=Delegate+Wallet+Address&user_field_ids=2%7C1%7C3&page=${page}`,
);
if (response.status !== 200 || !response.data) {
return false;
}
const results = response.data.directory_items
.filter(
(item) =>
item.user.user_fields["1"] !== undefined &&
item.user.user_fields["1"].value !== undefined &&
item.user.user_fields["1"].value[0] !== undefined &&
isAddress(item.user.user_fields["1"].value[0]) &&
item.user.user_fields["2"] !== undefined &&
item.user.user_fields["2"].value !== undefined &&
item.user.user_fields["2"].value[0] !== undefined,
)
.map((item) => {
const avatar = item.user.avatar_template.replace("{size}", "160");
return {
avatar: avatar.startsWith("/user_avatar")
? `https://dub1.discourse-cdn.com/flex017${avatar}`
: avatar,
name: item.user.username,
wallet: item.user.user_fields["1"].value[0],
pitch: {
intro: item.user.user_fields["2"].value[0],
url: item.user.user_fields["3"]?.value[0],
},
};
});
profiles.push(...results);
return response.data.directory_items.length > 0;
} catch (error) {
// If forum fetch fails, just return what we have
return false;
}
};
let page = 0;
let hasMore = true;
while (hasMore) {
hasMore = await getUsersPaginated(page);
page++;
}
return profiles;
};