diff --git a/apps/frontend/src/components/layout/Header.tsx b/apps/frontend/src/components/layout/Header.tsx index 1b70eb2d..5383d95f 100644 --- a/apps/frontend/src/components/layout/Header.tsx +++ b/apps/frontend/src/components/layout/Header.tsx @@ -2,9 +2,10 @@ import { useState, useCallback, useEffect } from 'react' import { NavLink, Link, useLocation } from 'react-router-dom' import styled, { css, keyframes } from 'styled-components' import { Button, EnsSVG, Profile, WalletSVG } from '@ensdomains/thorin' -import { useEnsAvatar, useEnsName } from 'wagmi' +import { useEnsName } from 'wagmi' import { mainnet } from 'viem/chains' import makeBlockie from 'ethereum-blockies-base64' +import { useVerifiedEnsAvatar } from '@/features/ens/ensAvatar' import { openWalletModal } from '@/features/wallet/openWalletModal' import { useWalletState } from '@/features/wallet/useWalletState' import { tokens } from '@/styles/tokens' @@ -322,10 +323,7 @@ function ConnectedAccount({ }) { const { data: resolvedEnsName } = useEnsName({ address, chainId: mainnet.id }) const ensName = resolvedEnsName ?? undefined - const { data: resolvedAvatar } = useEnsAvatar({ - name: ensName, - query: { enabled: !!ensName }, - }) + const resolvedAvatar = useVerifiedEnsAvatar(ensName) const avatar = resolvedAvatar ?? makeBlockie(address) return ( diff --git a/apps/frontend/src/components/shared/EnsAvatar.tsx b/apps/frontend/src/components/shared/EnsAvatar.tsx index 8fe1900d..5d962573 100644 --- a/apps/frontend/src/components/shared/EnsAvatar.tsx +++ b/apps/frontend/src/components/shared/EnsAvatar.tsx @@ -1,9 +1,10 @@ import { useMemo } from 'react' import styled from 'styled-components' import { Avatar } from '@ensdomains/thorin' -import { useEnsName, useEnsAvatar } from 'wagmi' +import { useEnsName } from 'wagmi' import makeBlockie from 'ethereum-blockies-base64' import { isAddress } from 'viem' +import { ensMetadataAvatarUrl } from '@/features/ens/ensAvatar' interface EnsAvatarProps { address: string @@ -35,22 +36,19 @@ export function EnsAvatar({ query: { enabled: resolveName && canResolveAddress && !name }, }) const ensName = name ?? resolvedName ?? undefined - const { data: resolvedAvatar } = useEnsAvatar({ - name: ensName, - query: { enabled: !!ensName && !avatarUrl }, - }) // Memoized: makeBlockie renders to a canvas, which adds up in long lists // (~1,200 avatars on the round detail page) if regenerated per render. - const src = useMemo( - () => avatarUrl ?? resolvedAvatar ?? makeBlockie(address), - [avatarUrl, resolvedAvatar, address], - ) + const blockie = useMemo(() => makeBlockie(address), [address]) + const src = avatarUrl ?? (ensName ? ensMetadataAvatarUrl(ensName) : blockie) return ( (null) + + useEffect(() => { + setUrl(null) + if (!name) return + let cancelled = false + const candidate = ensMetadataAvatarUrl(name) + const probe = new Image() + probe.onload = () => { + if (!cancelled) setUrl(candidate) + } + probe.src = candidate + return () => { + cancelled = true + } + }, [name]) + + return url +} diff --git a/apps/frontend/src/pages/LandingPage/sections/CtaSection.tsx b/apps/frontend/src/pages/LandingPage/sections/CtaSection.tsx index 0204b827..1ddef571 100644 --- a/apps/frontend/src/pages/LandingPage/sections/CtaSection.tsx +++ b/apps/frontend/src/pages/LandingPage/sections/CtaSection.tsx @@ -3,9 +3,10 @@ import styled, { keyframes } from 'styled-components' import { Link } from 'react-router-dom' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faArrowRight, faShareNodes } from '@fortawesome/free-solid-svg-icons' -import { useEnsAvatar, useEnsName } from 'wagmi' +import { useEnsName } from 'wagmi' import { isAddress } from 'viem' import makeBlockie from 'ethereum-blockies-base64' +import { ensMetadataAvatarUrl } from '@/features/ens/ensAvatar' import { api } from '@/api' import { useAsync } from '@/hooks/useAsync' import { truncateAddress } from '@/utils/format' @@ -287,14 +288,9 @@ function PillItem({ voter }: { voter: VoterDetail }) { const ensName = voter.ensName ?? resolvedName ?? null - const { data: resolvedAvatar } = useEnsAvatar({ - name: ensName ?? undefined, - query: { enabled: !!ensName && !voter.avatarUrl }, - }) - const fallbackSrc = buildFallbackAvatar(voter.address) const avatarSrc = - voter.avatarUrl ?? resolvedAvatar ?? fallbackSrc + voter.avatarUrl ?? (ensName ? ensMetadataAvatarUrl(ensName) : fallbackSrc) const label = ensName ?? truncateAddress(voter.address) return ( diff --git a/docs/algorithm.md b/docs/algorithm.md index 96090e12..65d5ef15 100644 --- a/docs/algorithm.md +++ b/docs/algorithm.md @@ -34,10 +34,10 @@ The last `PROPOSAL_WINDOW_SIZE = 10` non-active, non-canceled proposals are fetc ### Step 3 — Identify active voters -A voter is **active** if they cast a vote on at least `ACTIVE_VOTE_THRESHOLD = 7` of the 10 proposals. +A voter is **active** if they cast a vote on at least `ACTIVE_VOTE_THRESHOLD = 6` of the 10 proposals. ``` -isActive(voter) = count(votes by voter in last 10 proposals) >= 7 +isActive(voter) = count(votes by voter in last 10 proposals) >= 6 ``` Implemented in `active-voters.ts`. If no voters are active, an empty distribution is returned immediately. @@ -233,7 +233,7 @@ Key constants: ```typescript ONE_ENS = 10n ** 18n // 1 ENS in wei TWB_WINDOW_SECONDS = 180 * 86400 // 180 days (token-holder TWB window) -ACTIVE_VOTE_THRESHOLD = 7 +ACTIVE_VOTE_THRESHOLD = 6 PROPOSAL_WINDOW_SIZE = 10 MIN_REWARD_THRESHOLD = 1 ENS LOTTERY_TARGET_POOL_SIZE = 10 ENS diff --git a/docs/prd.md b/docs/prd.md index 6b741ecc..2616b58c 100644 --- a/docs/prd.md +++ b/docs/prd.md @@ -590,7 +590,7 @@ Cron job calls a script after month-end. The script: | Name | Value | Description | |---|---|---| -| `ACTIVE_VOTE_THRESHOLD` | 7 | Minimum votes in last 10 finalized proposals to qualify as an active voter | +| `ACTIVE_VOTE_THRESHOLD` | 6 | Minimum votes in last 10 finalized proposals to qualify as an active voter | | `PROPOSAL_WINDOW_SIZE` | 10 | Number of recent finalized proposals considered | | `VOTER_POOL_BPS` | 1,000 (10%) | Share of monthly pool allocated to active voters | | `TOKEN_HOLDER_POOL_BPS` | 9,000 (90%) | Share of monthly pool allocated to eligible token holders | diff --git a/packages/domain/src/config.ts b/packages/domain/src/config.ts index cd462804..7927f7b7 100644 --- a/packages/domain/src/config.ts +++ b/packages/domain/src/config.ts @@ -6,7 +6,7 @@ import { bps, seconds, wei } from "./types.js"; // ────────────────────────────────────────────────────────── /** Minimum votes in the proposal window to qualify as an active voter. */ -export const ACTIVE_VOTE_THRESHOLD = 7; +export const ACTIVE_VOTE_THRESHOLD = 6; /** Number of recent finalized proposals considered for activity. */ export const PROPOSAL_WINDOW_SIZE = 10; diff --git a/packages/domain/test/unit/active-voters.test.ts b/packages/domain/test/unit/active-voters.test.ts index 4279096d..a52075c6 100644 --- a/packages/domain/test/unit/active-voters.test.ts +++ b/packages/domain/test/unit/active-voters.test.ts @@ -124,22 +124,22 @@ describe("identifyActiveVoters", () => { expect(active.size).toBe(1); }); - it("voter who voted on exactly 7 proposals is active", () => { - const votes = proposals.slice(0, 7).map((p) => makeVote(alice, p.id)); + it("voter who voted on exactly 6 proposals is active", () => { + const votes = proposals.slice(0, 6).map((p) => makeVote(alice, p.id)); const active = identifyActiveVoters(proposals, votes); expect(active.has(alice)).toBe(true); }); - it("voter who voted on 6 proposals is not active", () => { - const votes = proposals.slice(0, 6).map((p) => makeVote(alice, p.id)); + it("voter who voted on 5 proposals is not active", () => { + const votes = proposals.slice(0, 5).map((p) => makeVote(alice, p.id)); const active = identifyActiveVoters(proposals, votes); expect(active.has(alice)).toBe(false); expect(active.size).toBe(0); }); - it("fewer than 10 proposals (e.g. 5) — no one can reach threshold of 7", () => { + it("fewer than 6 proposals (e.g. 5) — no one can reach threshold of 6", () => { const fiveProposals = proposals.slice(0, 5); const votes = fiveProposals.map((p) => makeVote(alice, p.id)); @@ -151,8 +151,8 @@ describe("identifyActiveVoters", () => { const votes = [ // Alice votes on all 10 ...proposals.map((p) => makeVote(alice, p.id)), - // Bob votes on 7 - ...proposals.slice(0, 7).map((p) => makeVote(bob, p.id)), + // Bob votes on exactly 6 (the threshold boundary) + ...proposals.slice(0, 6).map((p) => makeVote(bob, p.id)), // Carol votes on 3 ...proposals.slice(0, 3).map((p) => makeVote(carol, p.id)), ]; @@ -171,7 +171,7 @@ describe("identifyActiveVoters", () => { it("ignores votes on proposals not in the given set", () => { const votes = [ - ...proposals.slice(0, 6).map((p) => makeVote(alice, p.id)), + ...proposals.slice(0, 5).map((p) => makeVote(alice, p.id)), // These votes are for proposals not in the list makeVote(alice, "unknown-1"), makeVote(alice, "unknown-2"), @@ -184,14 +184,14 @@ describe("identifyActiveVoters", () => { it("duplicate votes on the same proposal count once", () => { const votes = [ - // Alice votes on 6 distinct proposals - ...proposals.slice(0, 6).map((p) => makeVote(alice, p.id)), - // Plus duplicate votes on those same 6 proposals - ...proposals.slice(0, 6).map((p) => makeVote(alice, p.id)), + // Alice votes on 5 distinct proposals + ...proposals.slice(0, 5).map((p) => makeVote(alice, p.id)), + // Plus duplicate votes on those same 5 proposals + ...proposals.slice(0, 5).map((p) => makeVote(alice, p.id)), ]; const active = identifyActiveVoters(proposals, votes); - // Still only 6 distinct proposals — not active + // Still only 5 distinct proposals — not active expect(active.has(alice)).toBe(false); });