Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions apps/frontend/src/components/layout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 (
<ConnectedAccountWrap>
Expand Down
16 changes: 7 additions & 9 deletions apps/frontend/src/components/shared/EnsAvatar.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 (
<Wrapper $size={size}>
<Avatar
label={ensName ?? address}
src={src}
// Shown while the avatar loads and kept when it errors (e.g. the
// metadata service 404s for names with no avatar record).
placeholder={`url("${blockie}")`}
shape="circle"
noBorder
loading={loading}
Expand Down
38 changes: 38 additions & 0 deletions apps/frontend/src/features/ens/ensAvatar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useEffect, useState } from 'react'

/**
* ENS metadata service avatar endpoint. Avatars are resolved server-side
* because client-side resolution (wagmi's useEnsAvatar) breaks on NFT
* avatar records hosted by the OpenSea Shared Storefront: their tokenURI
* points at the deprecated OpenSea v1 API, which sends no CORS headers.
* Returns 404 when the name has no avatar record.
*/
export function ensMetadataAvatarUrl(name: string): string {
return `https://metadata.ens.domains/mainnet/avatar/${encodeURIComponent(name)}`
}

/**
* Probes the metadata service for `name` and returns the avatar URL only
* once the image is known to load, so callers can fall back (e.g. to a
* blockie) instead of rendering a broken image on 404.
*/
export function useVerifiedEnsAvatar(name: string | undefined): string | null {
const [url, setUrl] = useState<string | null>(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
}
10 changes: 3 additions & 7 deletions apps/frontend/src/pages/LandingPage/sections/CtaSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 (
Expand Down
6 changes: 3 additions & 3 deletions docs/algorithm.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/prd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion packages/domain/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
26 changes: 13 additions & 13 deletions packages/domain/test/unit/active-voters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand All @@ -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)),
];
Expand All @@ -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"),
Expand All @@ -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);
});

Expand Down