@moonwell-fi/moonwell-sdk 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/_cjs/actions/governance/getDelegates.js +6 -1
  3. package/_cjs/actions/governance/getDelegates.js.map +1 -1
  4. package/_cjs/actions/governance/getUserVotingPowers.js +0 -10
  5. package/_cjs/actions/governance/getUserVotingPowers.js.map +1 -1
  6. package/_cjs/actions/governance/ipfs.js +45 -0
  7. package/_cjs/actions/governance/ipfs.js.map +1 -0
  8. package/_cjs/actions/governance/proposals/common.js +27 -3
  9. package/_cjs/actions/governance/proposals/common.js.map +1 -1
  10. package/_cjs/actions/governance/proposals/getProposal.js +6 -1
  11. package/_cjs/actions/governance/proposals/getProposal.js.map +1 -1
  12. package/_cjs/actions/governance/proposals/getProposals.js +13 -3
  13. package/_cjs/actions/governance/proposals/getProposals.js.map +1 -1
  14. package/_cjs/environments/definitions/ethereum/contracts.js +2 -1
  15. package/_cjs/environments/definitions/ethereum/contracts.js.map +1 -1
  16. package/_cjs/errors/version.js +1 -1
  17. package/_esm/actions/governance/getDelegates.js +7 -5
  18. package/_esm/actions/governance/getDelegates.js.map +1 -1
  19. package/_esm/actions/governance/getUserVotingPowers.js +0 -13
  20. package/_esm/actions/governance/getUserVotingPowers.js.map +1 -1
  21. package/_esm/actions/governance/ipfs.js +69 -0
  22. package/_esm/actions/governance/ipfs.js.map +1 -0
  23. package/_esm/actions/governance/proposals/common.js +43 -5
  24. package/_esm/actions/governance/proposals/common.js.map +1 -1
  25. package/_esm/actions/governance/proposals/getProposal.js +7 -2
  26. package/_esm/actions/governance/proposals/getProposal.js.map +1 -1
  27. package/_esm/actions/governance/proposals/getProposals.js +16 -4
  28. package/_esm/actions/governance/proposals/getProposals.js.map +1 -1
  29. package/_esm/environments/definitions/ethereum/contracts.js +2 -1
  30. package/_esm/environments/definitions/ethereum/contracts.js.map +1 -1
  31. package/_esm/errors/version.js +1 -1
  32. package/_types/actions/governance/getDelegates.d.ts.map +1 -1
  33. package/_types/actions/governance/getUserVotingPowers.d.ts.map +1 -1
  34. package/_types/actions/governance/ipfs.d.ts +33 -0
  35. package/_types/actions/governance/ipfs.d.ts.map +1 -0
  36. package/_types/actions/governance/proposals/common.d.ts +16 -1
  37. package/_types/actions/governance/proposals/common.d.ts.map +1 -1
  38. package/_types/actions/governance/proposals/getProposal.d.ts.map +1 -1
  39. package/_types/actions/governance/proposals/getProposals.d.ts.map +1 -1
  40. package/_types/client/createMoonwellClient.d.ts +4 -2
  41. package/_types/client/createMoonwellClient.d.ts.map +1 -1
  42. package/_types/environments/definitions/ethereum/contracts.d.ts +2 -1
  43. package/_types/environments/definitions/ethereum/contracts.d.ts.map +1 -1
  44. package/_types/environments/index.d.ts +2 -1
  45. package/_types/environments/index.d.ts.map +1 -1
  46. package/_types/errors/version.d.ts +1 -1
  47. package/actions/governance/getDelegates.ts +7 -5
  48. package/actions/governance/getUserVotingPowers.ts +0 -16
  49. package/actions/governance/ipfs.ts +82 -0
  50. package/actions/governance/proposals/common.ts +56 -4
  51. package/actions/governance/proposals/getProposal.ts +8 -0
  52. package/actions/governance/proposals/getProposals.ts +16 -2
  53. package/environments/definitions/ethereum/contracts.ts +2 -1
  54. package/errors/version.ts +1 -1
  55. package/package.json +1 -1
@@ -0,0 +1,82 @@
1
+ import type { Environment } from "../../environments/index.js";
2
+ import { getWithRetry } from "../axiosWithRetry.js";
3
+ import type { ApiProposal } from "./governor-api-client.js";
4
+
5
+ const PINATA_GATEWAY = "https://d4529a05.mypinata.cloud/ipfs";
6
+
7
+ // IPFS content is content-addressed, so a per-hash result is immutable and the
8
+ // cache lives for the process lifetime — no TTL needed. Repeated getProposals()
9
+ // calls in the same Node/browser session reuse the resolved markdown.
10
+ const ipfsContentCache = new Map<string, string>();
11
+
12
+ /**
13
+ * Strip the `ipfs://` prefix from an indexer-supplied URI. Returns null for
14
+ * anything that isn't an IPFS URI (including empty strings and undefined),
15
+ * which lets callers skip the fetch with a single check.
16
+ */
17
+ export const parseIpfsHash = (uri: string | undefined): string | null => {
18
+ if (!uri) return null;
19
+ return uri.match(/^ipfs:\/\/(.+)$/)?.[1] ?? null;
20
+ };
21
+
22
+ /**
23
+ * Fetch a single IPFS resource via the Pinata gateway. Cached by hash.
24
+ *
25
+ * `responseType: "text"` disables axios's default JSON auto-parse so we keep
26
+ * the markdown body verbatim. We additionally reject non-string responses so
27
+ * an HTML error page or JSON payload doesn't poison the cache as
28
+ * `"[object Object]"` — the caller's per-proposal catch keeps the `ipfs://`
29
+ * URI in place when that happens.
30
+ */
31
+ export const fetchIpfsContent = async (hash: string): Promise<string> => {
32
+ const cached = ipfsContentCache.get(hash);
33
+ if (cached !== undefined) return cached;
34
+
35
+ const response = await getWithRetry<string>(`${PINATA_GATEWAY}/${hash}`, {
36
+ responseType: "text",
37
+ });
38
+ if (typeof response.data !== "string") {
39
+ throw new Error(
40
+ `[fetchIpfsContent] non-string body for hash=${hash} (typeof=${typeof response.data})`,
41
+ );
42
+ }
43
+ ipfsContentCache.set(hash, response.data);
44
+ return response.data;
45
+ };
46
+
47
+ /**
48
+ * For every proposal whose `description` is an `ipfs://` URI, fetch the
49
+ * underlying markdown from Pinata and replace `description` in place. All
50
+ * fetches run in parallel.
51
+ *
52
+ * Failures are logged via console.warn AND surfaced through
53
+ * `env.onError` (matching the convention used by `getProposalData` /
54
+ * `getExtendedProposalData`), then swallowed per-proposal: the `ipfs://` URI
55
+ * is left untouched so consumers can detect (e.g. via
56
+ * `description.startsWith("ipfs://")`) and render a fallback. The bulk call
57
+ * never rejects, so a single bad pin doesn't kill `getProposals()` for
58
+ * everyone.
59
+ */
60
+ export const resolveIpfsDescriptions = async (
61
+ proposals: ApiProposal[],
62
+ env: Environment,
63
+ ): Promise<void> => {
64
+ await Promise.all(
65
+ proposals.map(async (proposal) => {
66
+ const hash = parseIpfsHash(proposal.description);
67
+ if (!hash) return;
68
+ try {
69
+ proposal.description = await fetchIpfsContent(hash);
70
+ } catch (error) {
71
+ console.warn(
72
+ `[resolveIpfsDescriptions] failed to fetch hash=${hash}:`,
73
+ error,
74
+ );
75
+ env.onError?.(error, {
76
+ source: "governance-ipfs-description",
77
+ chainId: proposal.chainId,
78
+ });
79
+ }
80
+ }),
81
+ );
82
+ };
@@ -262,12 +262,61 @@ const getLegacyArtemisMaxId = async (
262
262
  }
263
263
  };
264
264
 
265
+ /**
266
+ * Reads the multichain governor's quorum on every chain that holds an active
267
+ * proposal but isn't the one we'd normally read through. Returns a chainId →
268
+ * quorum map; chains with no `multichainGovernor` wired are omitted, as are
269
+ * chains whose quorum read reverted. Callers substitute `0n` for misses via
270
+ * the `options.crossChainQuorums?.get(...) ?? 0n` pattern.
271
+ *
272
+ * Read failures are routed through `onError` so Sentry-wired consumers see
273
+ * them — matching `getProposalData` / `getCrossChainProposalData` /
274
+ * `getExtendedProposalData`. The caller's `governanceEnvironment` carries
275
+ * `onError` since we don't have one per foreign chain in scope.
276
+ */
277
+ export const readCrossChainQuorums = async (
278
+ apiProposals: ApiProposal[],
279
+ governanceEnvironment: Environment,
280
+ ): Promise<Map<number, bigint>> => {
281
+ const otherChainIds = Array.from(
282
+ new Set(
283
+ apiProposals
284
+ .map((p) => p.chainId)
285
+ .filter((c) => c !== governanceEnvironment.chainId),
286
+ ),
287
+ );
288
+ const quorums = new Map<number, bigint>();
289
+ await Promise.all(
290
+ otherChainIds.map(async (chainId) => {
291
+ const env = (Object.values(publicEnvironments) as Environment[]).find(
292
+ (e) => e.chainId === chainId,
293
+ );
294
+ const mg = env?.contracts.multichainGovernor;
295
+ if (!mg) return;
296
+ try {
297
+ quorums.set(chainId, await mg.read.quorum());
298
+ } catch (error) {
299
+ console.warn(
300
+ `[readCrossChainQuorums] quorum read failed for chainId=${chainId}:`,
301
+ error,
302
+ );
303
+ governanceEnvironment.onError?.(error, {
304
+ source: "governance-cross-chain-quorum",
305
+ chainId,
306
+ });
307
+ }
308
+ }),
309
+ );
310
+ return quorums;
311
+ };
312
+
265
313
  /**
266
314
  * Fetches on-chain data for multiple proposals
267
315
  */
268
316
  export const getProposalsOnChainData = async (
269
317
  apiProposals: ApiProposal[],
270
318
  governanceEnvironment: Environment,
319
+ options?: { crossChainQuorums?: Map<number, bigint> },
271
320
  ): Promise<ProposalOnChainData[]> => {
272
321
  let quorum = 0n;
273
322
 
@@ -289,9 +338,12 @@ export const getProposalsOnChainData = async (
289
338
  const onChainDataList = await Promise.all(
290
339
  apiProposals.map(async (p) => {
291
340
  // Proposals from a different chain than the governance env (e.g. chainId=1
292
- // Ethereum proposals fetched through the Moonbeam env) can't be read from
293
- // this env's contracts — derive state from API events here so callers
294
- // never see a misleading `state: 0` default for finalized proposals.
341
+ // Ethereum proposals fetched through the Moonbeam env) can't have their
342
+ // state read from this env's contracts — derive state from API events here
343
+ // so callers never see a misleading `state: 0` default for finalized
344
+ // proposals. Quorum, however, comes from a per-chain map populated by the
345
+ // caller via `readCrossChainQuorums` (defaulting to 0n when the caller
346
+ // didn't pre-fetch, e.g. in unit tests).
295
347
  if (p.chainId !== governanceEnvironment.chainId) {
296
348
  const formatted = formatApiProposalData(p);
297
349
  return {
@@ -299,7 +351,7 @@ export const getProposalsOnChainData = async (
299
351
  proposalData: null,
300
352
  eta: 0,
301
353
  votesCollected: false,
302
- quorum: 0n,
354
+ quorum: options?.crossChainQuorums?.get(p.chainId) ?? 0n,
303
355
  };
304
356
  }
305
357
 
@@ -10,6 +10,7 @@ import {
10
10
  fetchProposal,
11
11
  isNotFoundError,
12
12
  } from "../governor-api-client.js";
13
+ import { resolveIpfsDescriptions } from "../ipfs.js";
13
14
  import {
14
15
  appendProposalExtendedData,
15
16
  formatApiProposalData,
@@ -18,6 +19,7 @@ import {
18
19
  getProposalData,
19
20
  getProposalsOnChainData,
20
21
  isMultichainProposal,
22
+ readCrossChainQuorums,
21
23
  } from "./common.js";
22
24
 
23
25
  export type GetProposalParameters<
@@ -91,10 +93,16 @@ async function getMoonbeamProposal(
91
93
  return undefined;
92
94
  }
93
95
 
96
+ const [, crossChainQuorums] = await Promise.all([
97
+ resolveIpfsDescriptions([apiProposal], governanceEnvironment),
98
+ readCrossChainQuorums([apiProposal], governanceEnvironment),
99
+ ]);
100
+
94
101
  const formattedData = formatApiProposalData(apiProposal);
95
102
  const onChainDataList = await getProposalsOnChainData(
96
103
  [apiProposal],
97
104
  governanceEnvironment,
105
+ { crossChainQuorums },
98
106
  );
99
107
  const onChainData = onChainDataList[0]!;
100
108
  const isMultichain = isMultichainProposal(apiProposal.targets);
@@ -6,6 +6,7 @@ import type { Chain, Environment } from "../../../environments/index.js";
6
6
  import * as logger from "../../../logger/console.js";
7
7
  import { type Proposal, ProposalState } from "../../../types/proposal.js";
8
8
  import { type ApiProposal, fetchAllProposals } from "../governor-api-client.js";
9
+ import { resolveIpfsDescriptions } from "../ipfs.js";
9
10
  import {
10
11
  appendProposalExtendedData,
11
12
  formatApiProposalData,
@@ -14,6 +15,7 @@ import {
14
15
  getProposalData,
15
16
  getProposalsOnChainData,
16
17
  isMultichainProposal,
18
+ readCrossChainQuorums,
17
19
  } from "./common.js";
18
20
 
19
21
  export type GetProposalsParameters<
@@ -93,19 +95,31 @@ async function getMoonbeamProposals(
93
95
  const chainsAttempted: ReadonlyArray<1 | 1284> = [1, 1284];
94
96
  const apiProposals: ApiProposal[] = [];
95
97
  results.forEach((result, index) => {
98
+ const chainId = chainsAttempted[index];
96
99
  if (result.status === "fulfilled") {
97
100
  apiProposals.push(...result.value);
98
- } else {
101
+ } else if (chainId !== undefined) {
99
102
  console.warn(
100
- `[getProposals] Failed to fetch proposals for chainId=${chainsAttempted[index]}; continuing with remaining chains.`,
103
+ `[getProposals] Failed to fetch proposals for chainId=${chainId}; continuing with remaining chains.`,
101
104
  result.reason,
102
105
  );
106
+ governanceEnvironment.onError?.(result.reason, {
107
+ source: "governance-proposals",
108
+ chainId,
109
+ });
103
110
  }
104
111
  });
105
112
 
113
+ // IPFS resolution and cross-chain quorum reads are independent — run them in
114
+ // parallel to save one network round-trip on the proposal list path.
115
+ const [, crossChainQuorums] = await Promise.all([
116
+ resolveIpfsDescriptions(apiProposals, governanceEnvironment),
117
+ readCrossChainQuorums(apiProposals, governanceEnvironment),
118
+ ]);
106
119
  const onChainDataList = await getProposalsOnChainData(
107
120
  apiProposals,
108
121
  governanceEnvironment,
122
+ { crossChainQuorums },
109
123
  );
110
124
 
111
125
  const proposals: Proposal[] = apiProposals.map((apiProposal, index) => {
@@ -6,6 +6,7 @@ export const contracts = createContractsConfig({
6
6
  contracts: {
7
7
  stakingToken: "stkWELL",
8
8
  governanceToken: "WELL",
9
- views: "0xF5f2ae75d762B7e2B42D53f48018436f52Ce5401",
9
+ views: "0xA061Ed814bBd1b03e8df0B7AbEbc40f4A6feb895",
10
+ multichainGovernor: "0x8769B70ac7c93AF0e75de0D69877709B66d75838",
10
11
  },
11
12
  });
package/errors/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '0.15.0'
1
+ export const version = '0.16.0'
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@moonwell-fi/moonwell-sdk",
3
3
  "description": "TypeScript Interface for Moonwell",
4
- "version": "0.15.0",
4
+ "version": "0.16.0",
5
5
  "main": "./_cjs/index.js",
6
6
  "module": "./_esm/index.js",
7
7
  "types": "./_types/index.d.ts",