@moonwell-fi/moonwell-sdk 0.19.1 → 0.20.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 (38) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/_cjs/actions/governance/getUserVotingPowers.js +15 -6
  3. package/_cjs/actions/governance/getUserVotingPowers.js.map +1 -1
  4. package/_cjs/actions/governance/proposals/common.js +15 -5
  5. package/_cjs/actions/governance/proposals/common.js.map +1 -1
  6. package/_cjs/actions/governance/proposals/getProposal.js +1 -1
  7. package/_cjs/actions/governance/proposals/getProposal.js.map +1 -1
  8. package/_cjs/actions/governance/proposals/getProposals.js +1 -1
  9. package/_cjs/actions/governance/proposals/getProposals.js.map +1 -1
  10. package/_cjs/common/getBlockNumberAtTimestamp.js +12 -2
  11. package/_cjs/common/getBlockNumberAtTimestamp.js.map +1 -1
  12. package/_cjs/errors/version.js +1 -1
  13. package/_esm/actions/governance/getUserVotingPowers.js +22 -6
  14. package/_esm/actions/governance/getUserVotingPowers.js.map +1 -1
  15. package/_esm/actions/governance/proposals/common.js +59 -19
  16. package/_esm/actions/governance/proposals/common.js.map +1 -1
  17. package/_esm/actions/governance/proposals/getProposal.js +6 -2
  18. package/_esm/actions/governance/proposals/getProposal.js.map +1 -1
  19. package/_esm/actions/governance/proposals/getProposals.js +5 -2
  20. package/_esm/actions/governance/proposals/getProposals.js.map +1 -1
  21. package/_esm/common/getBlockNumberAtTimestamp.js +36 -8
  22. package/_esm/common/getBlockNumberAtTimestamp.js.map +1 -1
  23. package/_esm/errors/version.js +1 -1
  24. package/_types/actions/governance/getUserVotingPowers.d.ts.map +1 -1
  25. package/_types/actions/governance/proposals/common.d.ts +48 -12
  26. package/_types/actions/governance/proposals/common.d.ts.map +1 -1
  27. package/_types/actions/governance/proposals/getProposal.d.ts.map +1 -1
  28. package/_types/actions/governance/proposals/getProposals.d.ts.map +1 -1
  29. package/_types/common/getBlockNumberAtTimestamp.d.ts +18 -3
  30. package/_types/common/getBlockNumberAtTimestamp.d.ts.map +1 -1
  31. package/_types/errors/version.d.ts +1 -1
  32. package/actions/governance/getUserVotingPowers.ts +30 -15
  33. package/actions/governance/proposals/common.ts +77 -21
  34. package/actions/governance/proposals/getProposal.ts +5 -2
  35. package/actions/governance/proposals/getProposals.ts +4 -2
  36. package/common/getBlockNumberAtTimestamp.ts +36 -8
  37. package/errors/version.ts +1 -1
  38. package/package.json +1 -1
@@ -100,25 +100,62 @@ export const extractProposalSubtitle = (input: string): string => {
100
100
  };
101
101
 
102
102
  /**
103
- * Detects if a proposal is a multichain proposal by checking whether any of
104
- * its targets is a Wormhole Core Bridge on a known multichain-governance hub
105
- * (Moonbeam or Ethereum). Bridges from both hubs are checked, so the result
106
- * is correct regardless of which chain the proposal was created on.
103
+ * Detects whether a proposal SENDS a cross-chain message, by checking whether
104
+ * any of its targets is a Wormhole Core Bridge on a known multichain-governance
105
+ * hub (Moonbeam or Ethereum).
106
+ *
107
+ * WARNING: this is NOT sufficient to classify a proposal as belonging to the
108
+ * multichain governor. Hub-local proposals (created on the Ethereum
109
+ * MultichainGovernor with only same-chain targets) never message a bridge and
110
+ * return `false` here — misclassifying them broke voting on proposal 171
111
+ * (June 2026). Use `classifyProposalMultichain` for governor classification.
107
112
  */
108
113
  export const isMultichainProposal = (targets?: string[]): boolean =>
109
114
  targets?.some((t) => MULTICHAIN_WORMHOLE_BRIDGES.has(t.toLowerCase())) ??
110
115
  false;
111
116
 
112
117
  /**
113
- * Routes a proposal to the multichain governor when:
114
- * - its targets include the Wormhole bridge (legacy detection), OR
115
- * - its proposalId is past the legacy Artemis governor's `proposalCount`,
116
- * which means it could only have been created on the multichain governor
117
- * (proposals migrated to the multichain governor after the cutoff but
118
- * can have local-only targets, e.g. Moonbeam-internal contract calls).
118
+ * True when `chainId`'s environment is a multichain-governance hub with no
119
+ * legacy governor lineage (Ethereum: `multichainGovernor` only, no Artemis
120
+ * predecessor). On such a chain every proposal belongs to the multichain
121
+ * governor by construction what the proposal targets is irrelevant.
122
+ */
123
+ export const isMultichainHomeChain = (chainId: number): boolean => {
124
+ const env = getEnvironmentByChainId(chainId);
125
+ return Boolean(env?.contracts.multichainGovernor && !env.contracts.governor);
126
+ };
127
+
128
+ /**
129
+ * Canonical multichain classification for a proposal — the single entry point
130
+ * used by `getProposal`, `getProposals`, and on-chain-data routing so the
131
+ * paths cannot drift. A proposal belongs to the multichain governor when:
132
+ * 1. it is homed on a hub chain with no legacy governor (every Ethereum-hub
133
+ * proposal, including hub-local ones with no bridge target), OR
134
+ * 2. its targets include a Wormhole Core Bridge (legacy detection), OR
135
+ * 3. its proposalId is past the legacy Artemis governor's `proposalCount`
136
+ * (Moonbeam-hub-era proposals with local-only targets), OR
137
+ * 4. the Artemis cutoff is unknown because the read failed (`undefined`) — we
138
+ * bias to multichain rather than risk routing a live proposal to the dead
139
+ * Artemis governor (the proposal-171 root cause, on Moonbeam).
119
140
  *
120
- * `legacyArtemisMaxId === 0` indicates the count read failed; in that case we
121
- * fall back to the targets-only heuristic.
141
+ * `legacyArtemisMaxId` is only meaningful for Moonbeam-homed proposals. Pass 0
142
+ * for chains with no legacy governor — the ID check is N/A and classification
143
+ * falls back to the home/bridge checks. Omit it or pass `undefined` when the
144
+ * count read failed so the unknown-cutoff bias above applies. (No `= 0` default:
145
+ * that would coerce an explicit `undefined` back to 0 and defeat the bias.)
146
+ */
147
+ export const classifyProposalMultichain = (
148
+ proposal: { targets?: string[]; proposalId: number; chainId: number },
149
+ legacyArtemisMaxId?: number,
150
+ ): boolean =>
151
+ isMultichainHomeChain(proposal.chainId) ||
152
+ isMultichainProposal(proposal.targets) ||
153
+ legacyArtemisMaxId === undefined ||
154
+ (legacyArtemisMaxId > 0 && proposal.proposalId > legacyArtemisMaxId);
155
+
156
+ /**
157
+ * @deprecated Use `classifyProposalMultichain` — this variant misses hub-homed
158
+ * proposals when the Artemis count is unavailable. Kept for compatibility.
122
159
  */
123
160
  export const isMultichainAware = (
124
161
  proposal: { targets?: string[]; proposalId: number },
@@ -236,6 +273,13 @@ export type ProposalOnChainData = {
236
273
  eta: number;
237
274
  votesCollected: boolean;
238
275
  quorum: bigint;
276
+ /**
277
+ * Canonical multichain classification, computed here with the caller env's
278
+ * Artemis cutoff. Returned so `getProposal`/`getProposals` consume it instead
279
+ * of re-running `classifyProposalMultichain` (which, without the cutoff,
280
+ * misses Moonbeam-homed local-target proposals and drifts from this routing).
281
+ */
282
+ isMultichain: boolean;
239
283
  };
240
284
 
241
285
  // Cached per chain: highest proposalId held by the legacy Artemis governor.
@@ -250,7 +294,7 @@ const legacyArtemisMaxIdCache = new Map<
250
294
 
251
295
  const getLegacyArtemisMaxId = async (
252
296
  governanceEnvironment: Environment,
253
- ): Promise<number> => {
297
+ ): Promise<number | undefined> => {
254
298
  const governor = governanceEnvironment.contracts.governor;
255
299
  if (!governor) return 0;
256
300
 
@@ -268,7 +312,13 @@ const getLegacyArtemisMaxId = async (
268
312
  return value;
269
313
  } catch (error) {
270
314
  console.warn("Failed to fetch legacy governor proposalCount:", error);
271
- return cached?.value ?? 0;
315
+ // A cold-cache read failure must NOT collapse to 0. On a dual-governor
316
+ // chain (Moonbeam) that would make a live multichain proposal with
317
+ // local-only targets look like a pre-cutoff legacy proposal and route its
318
+ // votes to the dead Artemis governor — the proposal-171 root cause. Return
319
+ // a stale cached cutoff if we have one, otherwise `undefined` (unknown) so
320
+ // `classifyProposalMultichain` biases to the multichain governor instead.
321
+ return cached?.value;
272
322
  }
273
323
  };
274
324
 
@@ -365,6 +415,17 @@ export const getProposalsOnChainData = async (
365
415
  : (options?.crossChainQuorums?.get(p.chainId) ?? 0n);
366
416
  const homeEnv = resolveHomeEnv(p.chainId);
367
417
 
418
+ // legacyArtemisMaxId is meaningful only for the caller's env — it's the
419
+ // Moonbeam Artemis cap; pass 0 for foreign envs. Hub-homed proposals
420
+ // (Ethereum multigov, no Artemis predecessor) classify as multichain
421
+ // regardless of targets via classifyProposalMultichain. Computed once
422
+ // here and returned on ProposalOnChainData so callers don't re-classify
423
+ // and drift (see getProposal/getProposals).
424
+ const isMultichain = classifyProposalMultichain(
425
+ p,
426
+ isLocal ? legacyArtemisMaxId : 0,
427
+ );
428
+
368
429
  if (!homeEnv) {
369
430
  const formatted = formatApiProposalData(p);
370
431
  return {
@@ -373,16 +434,10 @@ export const getProposalsOnChainData = async (
373
434
  eta: 0,
374
435
  votesCollected: false,
375
436
  quorum: proposalQuorum,
437
+ isMultichain,
376
438
  };
377
439
  }
378
440
 
379
- // legacyArtemisMaxId is meaningful only for the caller's env — it's the
380
- // Moonbeam Artemis cap. For foreign envs the targets-only heuristic is
381
- // the right check (Ethereum-hub multigov has no Artemis predecessor).
382
- const isMultichain = isLocal
383
- ? isMultichainAware(p, legacyArtemisMaxId)
384
- : isMultichainProposal(p.targets);
385
-
386
441
  const governorContract = isMultichain
387
442
  ? homeEnv.contracts.multichainGovernor
388
443
  : homeEnv.contracts.governor;
@@ -495,6 +550,7 @@ export const getProposalsOnChainData = async (
495
550
  eta,
496
551
  votesCollected,
497
552
  quorum: proposalQuorum,
553
+ isMultichain,
498
554
  };
499
555
  }),
500
556
  );
@@ -18,7 +18,6 @@ import {
18
18
  getExtendedProposalData,
19
19
  getProposalData,
20
20
  getProposalsOnChainData,
21
- isMultichainProposal,
22
21
  readCrossChainQuorums,
23
22
  } from "./common.js";
24
23
 
@@ -125,7 +124,11 @@ async function getMoonbeamProposal(
125
124
  { crossChainQuorums },
126
125
  );
127
126
  const onChainData = onChainDataList[0]!;
128
- const isMultichain = isMultichainProposal(apiProposal.targets);
127
+ // Single source of truth: getProposalsOnChainData already classified this
128
+ // proposal with the caller env's Artemis cutoff and used it to route the
129
+ // on-chain reads. Reusing it avoids the drift that left Moonbeam-homed
130
+ // local-target proposals (and hub-local Ethereum ones) without `multichain`.
131
+ const isMultichain = onChainData.isMultichain;
129
132
 
130
133
  const now = Math.floor(Date.now() / 1000);
131
134
  let proposalState = onChainData.state;
@@ -14,7 +14,6 @@ import {
14
14
  getExtendedProposalData,
15
15
  getProposalData,
16
16
  getProposalsOnChainData,
17
- isMultichainProposal,
18
17
  readCrossChainQuorums,
19
18
  } from "./common.js";
20
19
 
@@ -125,7 +124,10 @@ async function getMoonbeamProposals(
125
124
  const proposals: Proposal[] = apiProposals.map((apiProposal, index) => {
126
125
  const onChainData = onChainDataList[index]!;
127
126
  const formattedData = formatApiProposalData(apiProposal);
128
- const isMultichain = isMultichainProposal(apiProposal.targets);
127
+ // Single source of truth — see getProposal.ts. getProposalsOnChainData
128
+ // classified with the Artemis cutoff and routed the reads accordingly;
129
+ // re-classifying here without the cutoff would drift and drop `multichain`.
130
+ const isMultichain = onChainData.isMultichain;
129
131
 
130
132
  const now = Math.floor(Date.now() / 1000);
131
133
  let proposalState = onChainData.state;
@@ -1,18 +1,34 @@
1
1
  import type { PublicClient } from "viem";
2
2
 
3
3
  /**
4
- * Safety cap on interpolation iterations. With near-linear `ts(block)` the search
5
- * converges in ~3–5 reads; 8 leaves headroom for chains with variable block times
6
- * (e.g. Moonbeam) without unbounded RPC fan-out on pathological inputs.
4
+ * Cap on interpolation iterations before falling back to binary search. With
5
+ * near-linear `ts(block)` the interpolation phase converges in ~3–5 reads.
6
+ * On chains whose block time changed over their history the projection can
7
+ * fail to converge at all — see the binary completion phase below.
7
8
  */
8
- const MAX_ITERATIONS = 8;
9
+ const MAX_INTERPOLATION_ITERATIONS = 8;
9
10
 
10
11
  /**
11
12
  * Find the block number on a chain whose timestamp is the latest one ≤ the target unix timestamp.
12
13
  *
13
- * Uses interpolation search anchored on the latest block and block 1: each iteration
14
- * narrows the range by reading one block and projecting the target via the slope of the
15
- * remaining range. Converges in ~3–5 RPC calls even on chains with variable block times.
14
+ * Two phases:
15
+ *
16
+ * 1. Interpolation search anchored on the latest block and block 1: each iteration
17
+ * narrows the range by reading one block and projecting the target via the slope
18
+ * of the remaining range. Converges in ~3–5 RPC calls on chains with a roughly
19
+ * constant block time.
20
+ * 2. Binary-search completion: if interpolation hasn't converged after
21
+ * `MAX_INTERPOLATION_ITERATIONS`, finish with plain bisection — guaranteed
22
+ * convergence in ~log2(remaining range) reads.
23
+ *
24
+ * The completion phase is load-bearing, not defensive: on Moonbeam the block time
25
+ * changed ~12s → ~6s (async backing), so the interpolated projection — whose slope
26
+ * is the all-history average — lands after the target on every probe. Only the
27
+ * upper bound moves, the lower bound never leaves block 1, and the previous
28
+ * implementation silently returned that unconverged bound. Voting-power reads then
29
+ * executed against a block years before the views contract existed and reverted
30
+ * (governance proposal 171 incident, June 2026). An unconverged bound must never
31
+ * be returned.
16
32
  *
17
33
  * Assumes block 1 exists on the chain — true for every EVM chain Moonwell supports.
18
34
  *
@@ -30,12 +46,13 @@ export async function getBlockNumberAtTimestamp(
30
46
  const first = await publicClient.getBlock({ blockNumber: 1n });
31
47
  if (targetTimestamp <= first.timestamp) return first.number;
32
48
 
49
+ // Invariant throughout: ts(lo) <= targetTimestamp < ts(hi).
33
50
  let lo = first.number;
34
51
  let loTs = first.timestamp;
35
52
  let hi = latest.number;
36
53
  let hiTs = latest.timestamp;
37
54
 
38
- for (let i = 0; i < MAX_ITERATIONS; i += 1) {
55
+ for (let i = 0; i < MAX_INTERPOLATION_ITERATIONS; i += 1) {
39
56
  if (hi - lo <= 1n) break;
40
57
  const tsRange = hiTs - loTs;
41
58
  if (tsRange <= 0n) break;
@@ -55,5 +72,16 @@ export async function getBlockNumberAtTimestamp(
55
72
  }
56
73
  }
57
74
 
75
+ // Binary completion: never return an unconverged bound.
76
+ while (hi - lo > 1n) {
77
+ const mid = (lo + hi) / 2n;
78
+ const block = await publicClient.getBlock({ blockNumber: mid });
79
+ if (block.timestamp <= targetTimestamp) {
80
+ lo = mid;
81
+ } else {
82
+ hi = mid;
83
+ }
84
+ }
85
+
58
86
  return lo;
59
87
  }
package/errors/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '0.19.1'
1
+ export const version = '0.20.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.19.1",
4
+ "version": "0.20.0",
5
5
  "main": "./_cjs/index.js",
6
6
  "module": "./_esm/index.js",
7
7
  "types": "./_types/index.d.ts",