UNPKG

@moonwell-fi/moonwell-sdk

Version:

TypeScript Interface for Moonwell

812 lines (720 loc) 24.2 kB
import axios from "axios"; import last from "lodash/last.js"; import { moonriver } from "viem/chains"; import { Amount } from "../../../common/index.js"; import type { Environment } from "../../../environments/index.js"; import { publicEnvironments } from "../../../environments/index.js"; import { MultichainProposalStateMapping, type Proposal, type ProposalState, } from "../../../types/proposal.js"; import { postWithRetry } from "../../axiosWithRetry.js"; import type { ApiProposal } from "../governor-api-client.js"; export const WORMHOLE_CONTRACT = "0xc8e2b0cd52cf01b0ce87d389daa3d414d4ce29f3"; type PonderExtendedProposalData = { id: number; title: string; subtitle: string; description: string; targets: string[]; calldatas: string[]; signatures: string[]; stateChanges: { blockNumber: number; transactionHash: string; state: string; }[]; }; axios.defaults.timeout = 5_000; /** * Extract proposal subtitle from description */ export const extractProposalSubtitle = (input: string): string => { const lines = input.split("\n"); const h1Line = lines.find((line) => line.startsWith("#")); if (!h1Line) { return input ? input.substring(0, 100) : ""; } let result = h1Line.substring(1).trim(); const h2Index = result.indexOf("##"); if (h2Index !== -1) { result = result.substring(0, h2Index).trim(); } result = result.replace(/\\n/g, "").trim(); if (result.length > 80) { result = `${result.substring(0, 80)}...`; } // Special cases for proposals that don't follow the standard naming convention if (result.includes("Moonbeam")) { result = result.replace("MIP-B", "MIP-M"); } if (result.indexOf("MIP-B22: Gauntlet") >= 0) { result = result.replace("MIP-B22", "MIP-B24"); } if (result.indexOf("MIP-O01: Gauntlet") >= 0) { result = result.replace("MIP-O01", "MIP-O03"); } if (result.indexOf("MIP-M02: Upgrade") >= 0) { result = result.replace("MIP-M02", "MIP-M03"); } if (result.indexOf("MIP-R02: Upgrade") >= 0) { result = result.replace("MIP-R02", "MIP-R03"); } if (result.indexOf("Proposal: Onboard wstETH") >= 0) { result = result.replace("Proposal:", "MIP-B08"); } if ( result.indexOf("Gauntlet's Moonriver Recommendations (2024-01-09)") >= 0 ) { result = result.replace("Gauntlet", "MIP-R10: Gauntlet"); } return result; }; /** * Detects if a proposal is a multichain proposal */ export const isMultichainProposal = (targets?: string[]): boolean => { return ( targets?.some( (target) => target.toLowerCase() === WORMHOLE_CONTRACT.toLowerCase(), ) ?? false ); }; /** * Routes a proposal to the multichain governor when: * - its targets include the Wormhole bridge (legacy detection), OR * - its proposalId is past the legacy Artemis governor's `proposalCount`, * which means it could only have been created on the multichain governor * (proposals migrated to the multichain governor after the cutoff but * can have local-only targets, e.g. Moonbeam-internal contract calls). * * `legacyArtemisMaxId === 0` indicates the count read failed; in that case we * fall back to the targets-only heuristic. */ export const isMultichainAware = ( proposal: { targets?: string[]; proposalId: number }, legacyArtemisMaxId: number, ): boolean => isMultichainProposal(proposal.targets) || (legacyArtemisMaxId > 0 && proposal.proposalId > legacyArtemisMaxId); export type ApiProposalFormatted = { forVotes: Amount; againstVotes: Amount; abstainVotes: Amount; totalVotes: Amount; canceled: boolean; executed: boolean; stateChanges: Array<{ blockNumber: number; transactionHash: string; state: string; chainId: number; }>; title: string; subtitle: string; }; /** * Parses and formats API proposal data */ export const formatApiProposalData = ( apiProposal: ApiProposal, ): ApiProposalFormatted => { const forVotesNum = Number(apiProposal.forVotes); const againstVotesNum = Number(apiProposal.againstVotes); const abstainVotesNum = Number(apiProposal.abstainVotes); const forVotes = new Amount(BigInt(Math.floor(forVotesNum * 1e18)), 18); const againstVotes = new Amount( BigInt(Math.floor(againstVotesNum * 1e18)), 18, ); const abstainVotes = new Amount( BigInt(Math.floor(abstainVotesNum * 1e18)), 18, ); const totalVotesValue = forVotesNum + againstVotesNum + abstainVotesNum; const totalVotes = new Amount(BigInt(Math.floor(totalVotesValue * 1e18)), 18); const canceled = apiProposal.stateChanges?.some((sc: any) => sc.state === "CANCELED") ?? false; const executed = apiProposal.stateChanges?.some((sc: any) => sc.state === "EXECUTED") ?? false; // IMPORTANT: Use sc.chainId from the state change, not apiProposal.chainId // This preserves cross-chain events (e.g., QUEUED/EXECUTED on Base with chainId 8453) const stateChanges = apiProposal.stateChanges?.map((sc: any) => ({ blockNumber: Number(sc.blockNumber), transactionHash: sc.transactionHash, state: sc.state, chainId: sc.chainId, // Uses the chainId from the state change itself timestamp: sc.timestamp !== undefined ? Number(sc.timestamp) : undefined, })) || []; const subtitle = extractProposalSubtitle(apiProposal.description); const title = `Proposal #${apiProposal.proposalId}`; return { forVotes, againstVotes, abstainVotes, totalVotes, canceled, executed, stateChanges, title, subtitle, }; }; export type ProposalOnChainData = { state: number; proposalData: any; eta: number; votesCollected: boolean; quorum: bigint; }; // Cached per chain: highest proposalId held by the legacy Artemis governor. // Anything with a higher proposalId belongs to the multichain governor, even // if its targets don't include the Wormhole bridge. The legacy governor only // receives new proposals during chain migrations, so a 5-minute TTL is plenty. const LEGACY_ARTEMIS_MAX_ID_TTL_MS = 5 * 60 * 1000; const legacyArtemisMaxIdCache = new Map< number, { value: number; fetchedAt: number } >(); const getLegacyArtemisMaxId = async ( governanceEnvironment: Environment, ): Promise<number> => { const governor = governanceEnvironment.contracts.governor; if (!governor) return 0; const cached = legacyArtemisMaxIdCache.get(governanceEnvironment.chainId); if (cached && Date.now() - cached.fetchedAt < LEGACY_ARTEMIS_MAX_ID_TTL_MS) { return cached.value; } try { const value = Number(await governor.read.proposalCount()); legacyArtemisMaxIdCache.set(governanceEnvironment.chainId, { value, fetchedAt: Date.now(), }); return value; } catch (error) { console.warn("Failed to fetch legacy governor proposalCount:", error); return cached?.value ?? 0; } }; /** * Fetches on-chain data for multiple proposals */ export const getProposalsOnChainData = async ( apiProposals: ApiProposal[], governanceEnvironment: Environment, ): Promise<ProposalOnChainData[]> => { let quorum = 0n; if (governanceEnvironment.contracts.governor) { try { quorum = governanceEnvironment.chainId === 1284 ? await governanceEnvironment.contracts.governor.read.quorumVotes() : await governanceEnvironment.contracts.governor.read.getQuorum(); } catch (error) { console.warn("Failed to fetch quorum:", error); } } const legacyArtemisMaxId = await getLegacyArtemisMaxId(governanceEnvironment); const onChainDataList = await Promise.all( apiProposals.map(async (p) => { const isMultichain = isMultichainAware(p, legacyArtemisMaxId); const governorContract = isMultichain ? governanceEnvironment.contracts.multichainGovernor : governanceEnvironment.contracts.governor; let state = 0; let proposalData = null; if (governorContract) { try { [state, proposalData] = await Promise.all([ governorContract.read.state([BigInt(p.proposalId)]), governorContract.read.proposals([BigInt(p.proposalId)]), ]); } catch (error) { console.warn("Failed to fetch state and proposalData:", error); } } let eta = 0; if (proposalData) { const onChainEta = Number(proposalData[4]); if (onChainEta === 0 && isMultichain && p.votingEndTime) { eta = p.votingEndTime + 86400; // 1 day } else { eta = onChainEta; } } else if (isMultichain && p.votingEndTime) { eta = p.votingEndTime + 86400; // 1 day } return { state, proposalData, eta, votesCollected: false, quorum }; }), ); const votesCollectedList = await Promise.all( apiProposals.map(async (apiProposal) => { const isMultichain = isMultichainAware(apiProposal, legacyArtemisMaxId); if ( !isMultichain || !governanceEnvironment.contracts.multichainGovernor ) { return false; } const xcGovernanceSettings = governanceEnvironment.custom.governance; if (!xcGovernanceSettings || xcGovernanceSettings.chainIds.length === 0) { return false; } try { const xcEnvironments = xcGovernanceSettings.chainIds .map((chainId) => Object.values(publicEnvironments).find( (env) => env.chainId === chainId, ), ) .filter((env) => { if (!env) return false; const hasWormhole = env.custom && "wormhole" in env.custom && env.custom.wormhole?.chainId; const hasVoteCollector = env.contracts && "voteCollector" in env.contracts && env.contracts.voteCollector; return !!(hasWormhole && hasVoteCollector); }); if (xcEnvironments.length === 0) { return false; } const votesCollectedChecks = await Promise.all( xcEnvironments.map(async (xcEnvironment) => { try { const wormholeChainId = (xcEnvironment!.custom as any)?.wormhole ?.chainId; if (!wormholeChainId) return false; const [forVotes, againstVotes, abstainVotes] = await governanceEnvironment.contracts.multichainGovernor!.read.chainVoteCollectorVotes( [wormholeChainId, BigInt(apiProposal.proposalId)], ); return forVotes > 0n || againstVotes > 0n || abstainVotes > 0n; } catch (error) { return false; } }), ); return ( votesCollectedChecks.length > 0 && votesCollectedChecks.every((collected) => collected) ); } catch (error) { console.warn("Failed to check votes collected status:", error); return false; } }), ); return onChainDataList.map((data, index) => ({ ...data, votesCollected: votesCollectedList[index] ?? false, })); }; /** * Get proposal data from on-chain (Ponder-based, for Moonriver) */ export const getProposalData = async (params: { environment: Environment; id?: number; }) => { try { if (params.environment.contracts.governor) { let count = 0n; let quorum = 0n; if (params.environment.chainId === moonriver.id) { [count, quorum] = await Promise.all([ params.environment.contracts.governor.read.proposalCount(), params.environment.contracts.governor.read.getQuorum(), ]); } else { [count, quorum] = await Promise.all([ params.environment.contracts.governor.read.proposalCount(), params.environment.contracts.governor.read.quorumVotes(), ]); } if (params.id) { if (BigInt(params.id) > count) { return []; } } const ids = params.id ? [BigInt(params.id)] : Array.from({ length: Number(count) }, (_, i) => count - BigInt(i)); const proposalDataCall = Promise.all( ids.map((id) => params.environment.contracts.governor?.read.proposals([id]), ), ); const proposalStateCall = Promise.all( ids.map((id) => params.environment.contracts.governor?.read.state([id]), ), ); const [proposalsData, proposalsState] = await Promise.all([ proposalDataCall, proposalStateCall, ]); const proposals = proposalsData?.map((item, index: number) => { const state = proposalsState?.[index]!; const [ id, proposer, eta, startTimestamp, endTimestamp, startBlock, forVotes, againstVotes, abstainVotes, totalVotes, canceled, executed, ] = item!; const proposal: Proposal = { chainId: params.environment.chainId, id: Number(id), proposalId: Number(id), proposer, eta: Number(eta), startTimestamp: Number(startTimestamp), endTimestamp: Number(endTimestamp), startBlock: Number(startBlock), forVotes: new Amount(forVotes, 18), againstVotes: new Amount(againstVotes, 18), abstainVotes: new Amount(abstainVotes, 18), totalVotes: new Amount(totalVotes, 18), canceled, executed, quorum: new Amount(quorum, 18), state, }; return proposal; }); return proposals; } else { return []; } } catch (error) { console.warn( `[getProposalData] RPC failed for chain ${params.environment.chainId}:`, error, ); params.environment.onError?.(error, { source: "governance-proposals", chainId: params.environment.chainId, }); return []; } }; /** * Get cross-chain proposal data (Ponder-based, for Moonriver) */ export const getCrossChainProposalData = async (params: { environment: Environment; id?: number; }) => { try { if (params.environment.contracts.governor) { const xcGovernanceSettings = params.environment.custom.governance; if ( params.environment.contracts.multichainGovernor && xcGovernanceSettings && xcGovernanceSettings.chainIds.length > 0 ) { const xcEnvironments = xcGovernanceSettings.chainIds .map((chainId) => (Object.values(publicEnvironments) as Environment[]).find( (env) => env.chainId === chainId, ), ) .filter((xcEnvironment) => !!xcEnvironment) .filter( (xcEnvironment) => xcEnvironment!.custom?.wormhole?.chainId && xcEnvironment!.contracts.voteCollector, ); const [xcCount, xcQuorum] = await Promise.all([ params.environment.contracts.multichainGovernor.read.proposalCount(), params.environment.contracts.multichainGovernor.read.quorum(), ]); if (params.id) { params.id = Number(params.id) - (params.environment.custom?.governance?.proposalIdOffset || 0); if (params.id < 0) return []; if (BigInt(params.id) > xcCount) return []; } const ids = params.id ? [BigInt(params.id)] : Array.from( { length: Number(xcCount) }, (_, i) => xcCount - BigInt(i), ); const xcProposalsDataCall = Promise.all( ids.map((id) => params.environment.contracts.multichainGovernor?.read.proposals([ id, ]), ), ); const xcProposalsStateCall = Promise.all( ids.map((id) => params.environment.contracts.multichainGovernor?.read.state([id]), ), ); const xcVotesCall = Promise.all( xcEnvironments.map((xcEnvironment) => Promise.all( ids.map((id) => params.environment.contracts.multichainGovernor?.read.chainVoteCollectorVotes( [(xcEnvironment!.custom as any).wormhole.chainId, id], ), ), ), ), ); const [xcProposalsData, xcProposalsState, xcVotes] = await Promise.all([ xcProposalsDataCall, xcProposalsStateCall, xcVotesCall, ]); const proposals = ids.map((xcId, proposalIndex: number) => { const state = xcProposalsState?.[proposalIndex]!; const id = Number(xcId) + (params.environment.custom?.governance?.proposalIdOffset || 0); const votesCollected = false; const votes = xcVotes.reduce( (prevVotes, currVotes) => { const voteData = currVotes[proposalIndex]; if (!voteData) { return prevVotes; } // chainVoteCollectorVotes returns [forVotes, againstVotes, abstainVotes] const forVotes = voteData[0] || 0n; const againstVotes = voteData[1] || 0n; const abstainVotes = voteData[2] || 0n; const totalVotes = forVotes + againstVotes + abstainVotes; return { totalVotes: prevVotes.totalVotes + totalVotes, forVotes: prevVotes.forVotes + forVotes, againstVotes: prevVotes.againstVotes + againstVotes, abstainVotes: prevVotes.abstainVotes + abstainVotes, }; }, { totalVotes: 0n, forVotes: 0n, againstVotes: 0n, abstainVotes: 0n, }, ); const proposalData = xcProposalsData?.[proposalIndex]; if (!proposalData) { throw new Error( `Proposal data not found for index ${proposalIndex}`, ); } const [ proposer, _voteSnapshotTimestamp, votingStartTime, votingEndTime, crossChainVoteCollectionEndTimestamp, voteSnapshotBlock, proposalForVotes, proposalAgainstVotes, proposalAbstainVotes, proposalTotalVotes, canceled, executed, ] = proposalData; const multichainState = ( MultichainProposalStateMapping as { [key: number]: ProposalState } )[state]!; const proposal: Proposal = { chainId: params.environment.chainId, id, proposalId: Number(xcId), proposer, eta: Number(crossChainVoteCollectionEndTimestamp), startTimestamp: Number(votingStartTime), endTimestamp: Number(votingEndTime), startBlock: Number(voteSnapshotBlock), forVotes: new Amount(proposalForVotes + votes.forVotes, 18), againstVotes: new Amount( proposalAgainstVotes + votes.againstVotes, 18, ), abstainVotes: new Amount( proposalAbstainVotes + votes.abstainVotes, 18, ), totalVotes: new Amount(proposalTotalVotes + votes.totalVotes, 18), canceled, executed, quorum: new Amount(xcQuorum, 18), state: multichainState, multichain: { id: Number(xcId), votesCollected, }, }; return proposal; }); return proposals; } else { return []; } } else { return []; } } catch (error) { console.warn( `[getCrossChainProposalData] RPC failed for chain ${params.environment.chainId}:`, error, ); params.environment.onError?.(error, { source: "governance-proposals", chainId: params.environment.chainId, }); return []; } }; export const appendProposalExtendedData = ( proposals: Proposal[], extendedDatas: PonderExtendedProposalData[], ) => { proposals.forEach((proposal) => { const extendedData = extendedDatas.find( (item) => item.id === proposal.proposalId, ); if (extendedData) { proposal.title = extendedData.title; proposal.calldatas = extendedData.calldatas; proposal.description = extendedData.description; proposal.signatures = extendedData.signatures; proposal.stateChanges = extendedData.stateChanges.map((change) => ({ blockNumber: change.blockNumber, transactionHash: change.transactionHash, state: change.state, chainId: proposal.chainId, })); proposal.subtitle = extendedData.subtitle; proposal.targets = extendedData.targets; } }); }; export const getExtendedProposalData = async (params: { environment: Environment; id?: number; }): Promise<PonderExtendedProposalData[]> => { const result: PonderExtendedProposalData[] = []; let lastId = -1; let shouldContinue = true; const MAX_PAGES = 100; let page = 0; try { while (shouldContinue && page < MAX_PAGES) { page++; const response = await postWithRetry<{ data: { proposals: { items: { proposalId: number; description: string; targets: string[]; calldatas: string[]; signatures: string[]; stateChanges: { items: { txnHash: string; blockNumber: number; newState: string; }[]; }; }[]; }; }; }>("https://ponder-eu2.moonwell.fi", { query: ` query { proposals( limit: 1000, orderDirection: "desc", orderBy: "proposalId", where: { chainId: ${params.environment.chainId} ${params.id ? `, proposalId: ${params.id}` : lastId >= 0 ? `, proposalId_lt: ${lastId}` : ""} } ) { items { id proposalId description targets calldatas signatures stateChanges(orderBy: "blockNumber") { items { txnHash blockNumber newState } } } } } `, }); if (response.status === 200 && response.data?.data?.proposals) { const proposals = response.data.data.proposals.items.map((item) => { const extendedProposalData: PonderExtendedProposalData = { id: item.proposalId, title: `Proposal #${item.proposalId}`, subtitle: extractProposalSubtitle(item.description), description: item.description, calldatas: item.calldatas, signatures: item.signatures, stateChanges: item.stateChanges.items.map((change) => { return { blockNumber: change.blockNumber, state: change.newState, transactionHash: change.txnHash, }; }), targets: item.targets, }; return extendedProposalData; }); if (proposals.length < 1000 || proposals.length === 0) { shouldContinue = false; } else { lastId = last(proposals)!.id; } result.push(...proposals); } else { shouldContinue = false; } } } catch (error) { console.warn( `[getExtendedProposalData] Ponder failed for chain ${params.environment.chainId}:`, error, ); params.environment.onError?.(error, { source: "governance-proposals", chainId: params.environment.chainId, }); return result; } return result; };