Skip to content

Commit 47e9be6

Browse files
committed
fix(epoch1000): address PR review feedback for validator edition
- Fix Trillium lookup when sw_first_epoch_with_stake is missing on latest epoch row - Add Array.isArray guard on Trillium API response - Harden OG validator logo fetch with HTTPS host allowlist and cache cap - Move ValidatorChecker strings to i18n
1 parent 84735ca commit 47e9be6

5 files changed

Lines changed: 119 additions & 35 deletions

File tree

apps/web/src/app/api/epoch1000/og/route.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,39 @@ function getSolanaLogo() {
102102
}
103103

104104
const remoteImageCache = new Map<string, Promise<string | null>>();
105+
const MAX_REMOTE_IMAGE_CACHE = 64;
106+
const ALLOWED_REMOTE_IMAGE_HOSTS = new Set(["trillium.so", "s3.amazonaws.com"]);
107+
108+
function isAllowedRemoteImageUrl(urlString: string): boolean {
109+
let url: URL;
110+
try {
111+
url = new URL(urlString);
112+
} catch {
113+
return false;
114+
}
115+
116+
if (url.protocol !== "https:") return false;
117+
if (!ALLOWED_REMOTE_IMAGE_HOSTS.has(url.hostname)) return false;
118+
if (/^\d+\.\d+\.\d+\.\d+$/.test(url.hostname)) return false;
119+
if (url.hostname === "localhost" || url.hostname.endsWith(".local")) {
120+
return false;
121+
}
122+
123+
return true;
124+
}
105125

106126
function getRemoteImageDataUrl(url: string): Promise<string | null> {
127+
if (!isAllowedRemoteImageUrl(url)) {
128+
return Promise.resolve(null);
129+
}
130+
107131
let cached = remoteImageCache.get(url);
108132
if (!cached) {
133+
if (remoteImageCache.size >= MAX_REMOTE_IMAGE_CACHE) {
134+
const oldest = remoteImageCache.keys().next().value;
135+
if (oldest) remoteImageCache.delete(oldest);
136+
}
137+
109138
cached = fetch(url)
110139
.then(async (res) => {
111140
if (!res.ok) return null;

apps/web/src/components/epoch1000/ValidatorChecker.tsx

Lines changed: 48 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,38 @@
11
"use client";
22

3-
import { useState } from "react";
3+
import { useMemo, useState } from "react";
4+
import { useTranslations } from "next-intl";
45
import { cardParams, type CheckResult } from "@/lib/epoch1000/card-params";
56
import {
67
isValidSolanaAddress,
78
SOLANA_ADDRESS_ERROR,
89
} from "@/lib/epoch1000/public-key";
910

10-
const LOADING_LINES = [
11-
"checking...",
12-
"finding first vote...",
13-
"scanning validator history...",
14-
"building card...",
15-
];
11+
const LOADING_LINE_KEYS = [
12+
"loadingChecking",
13+
"loadingFindingVote",
14+
"loadingScanning",
15+
"loadingBuilding",
16+
] as const;
1617

1718
interface Props {
1819
onResult?: (_result: CheckResult | null) => void;
1920
}
2021

2122
export default function ValidatorChecker({ onResult }: Props) {
23+
const t = useTranslations("epoch1000.validator.checker");
2224
const [address, setAddress] = useState("");
2325
const [loading, setLoading] = useState(false);
24-
const [loadingLine, setLoadingLine] = useState(LOADING_LINES[0]);
26+
const [loadingLine, setLoadingLine] = useState<
27+
(typeof LOADING_LINE_KEYS)[number]
28+
>(LOADING_LINE_KEYS[0]);
2529
const [lookupError, setLookupError] = useState<string | null>(null);
2630
const [result, setResult] = useState<CheckResult | null>(null);
2731
const [showAddress, setShowAddress] = useState(true);
2832
const [copied, setCopied] = useState(false);
2933

34+
const numberFormatter = useMemo(() => new Intl.NumberFormat("en-US"), []);
35+
3036
async function check(e: React.FormEvent) {
3137
e.preventDefault();
3238
const trimmed = address.trim();
@@ -43,7 +49,9 @@ export default function ValidatorChecker({ onResult }: Props) {
4349
const rotator = setInterval(
4450
() =>
4551
setLoadingLine(
46-
LOADING_LINES[Math.floor(Math.random() * LOADING_LINES.length)],
52+
LOADING_LINE_KEYS[
53+
Math.floor(Math.random() * LOADING_LINE_KEYS.length)
54+
],
4755
),
4856
2500,
4957
);
@@ -55,13 +63,13 @@ export default function ValidatorChecker({ onResult }: Props) {
5563
});
5664
const json = await res.json();
5765
if (!res.ok) {
58-
setLookupError(json.error ?? "Lookup failed. Try again.");
66+
setLookupError(json.error ?? t("lookupFailed"));
5967
} else {
6068
setResult(json as CheckResult);
6169
onResult?.(json as CheckResult);
6270
}
6371
} catch {
64-
setLookupError("Lookup failed. Try again.");
72+
setLookupError(t("lookupFailed"));
6573
} finally {
6674
clearInterval(rotator);
6775
setLoading(false);
@@ -74,7 +82,12 @@ export default function ValidatorChecker({ onResult }: Props) {
7482
: "";
7583
const tweet = result
7684
? encodeURIComponent(
77-
`I've validated ${result.epochsSurvived}${result.capped ? "+" : ""} Solana epochs - ${result.tier}.\n\n${cardUrl}`,
85+
t("tweet", {
86+
epochsSurvived: result.epochsSurvived,
87+
capped: result.capped ? "+" : "",
88+
tier: result.tier,
89+
cardUrl,
90+
}),
7891
)
7992
: "";
8093

@@ -99,12 +112,13 @@ export default function ValidatorChecker({ onResult }: Props) {
99112
<section id="checker" className="flex flex-col gap-6">
100113
<div>
101114
<h2 className="font-bold tracking-tight text-3xl sm:text-4xl">
102-
Check <span className="text-sol-gradient">validator</span>
115+
{t.rich("heading", {
116+
gradient: (chunks) => (
117+
<span className="text-sol-gradient">{chunks}</span>
118+
),
119+
})}
103120
</h2>
104-
<p className="mt-2 text-sm text-ep-dim">
105-
Enter a vote account to find your first validating epoch and share
106-
your survivor card. No connect, no signature.
107-
</p>
121+
<p className="mt-2 text-sm text-ep-dim">{t("description")}</p>
108122
</div>
109123

110124
<form onSubmit={check} className="flex flex-col sm:flex-row gap-3">
@@ -114,10 +128,10 @@ export default function ValidatorChecker({ onResult }: Props) {
114128
setAddress(e.target.value);
115129
setLookupError(null);
116130
}}
117-
placeholder="vote account address"
131+
placeholder={t("placeholder")}
118132
spellCheck={false}
119133
autoComplete="off"
120-
aria-label="Validator vote account address"
134+
aria-label={t("inputAriaLabel")}
121135
aria-invalid={validationError ? true : undefined}
122136
aria-describedby={
123137
displayedError ? "epoch1000-validator-error" : undefined
@@ -129,13 +143,13 @@ export default function ValidatorChecker({ onResult }: Props) {
129143
disabled={loading || !isAddressValid}
130144
className="!bg-ep-ink text-ep-void font-semibold rounded-full px-7 py-3 text-sm disabled:opacity-40 disabled:cursor-not-allowed hover:!bg-ep-dim transition"
131145
>
132-
{loading ? "Checking..." : "Check"}
146+
{loading ? t("submitLoading") : t("submit")}
133147
</button>
134148
</form>
135149

136150
{loading && (
137151
<p className="text-sm text-ep-dust animate-pulse" aria-live="polite">
138-
{loadingLine}
152+
{t(loadingLine)}
139153
</p>
140154
)}
141155

@@ -154,7 +168,10 @@ export default function ValidatorChecker({ onResult }: Props) {
154168
<img
155169
key={params}
156170
src={`/api/epoch1000/og?${params}`}
157-
alt={`Validator card: first seen epoch ${result.firstEpoch}, validated ${result.epochsSurvived} epochs`}
171+
alt={t("imageAlt", {
172+
firstEpoch: result.firstEpoch,
173+
epochsSurvived: result.epochsSurvived,
174+
})}
158175
className="w-full rounded-lg border border-ep-edge"
159176
width={1200}
160177
height={630}
@@ -168,41 +185,43 @@ export default function ValidatorChecker({ onResult }: Props) {
168185
onChange={(e) => setShowAddress(e.target.checked)}
169186
className="accent-[#14f195]"
170187
/>
171-
Show vote account
188+
{t("showVoteAccount")}
172189
</label>
173190
<a
174191
href={`https://twitter.com/intent/tweet?text=${tweet}`}
175192
target="_blank"
176193
rel="noopener noreferrer"
177194
className="bg-ep-ink text-ep-void font-semibold rounded-full px-5 py-2 hover:bg-ep-dim transition"
178195
>
179-
Share
196+
{t("share")}
180197
</a>
181198
<button
182199
onClick={copyLink}
183200
className="border border-ep-edge rounded-full px-5 py-2 text-ep-dim hover:text-ep-ink transition"
184201
>
185-
{copied ? "Copied" : "Copy link"}
202+
{copied ? t("copied") : t("copyLink")}
186203
</button>
187204
<a
188205
href={`/api/epoch1000/og?${params}`}
189206
download={`epoch1000-validator-${result.firstEpoch}.png`}
190207
className="border border-ep-edge rounded-full px-5 py-2 text-ep-dim hover:text-ep-ink transition"
191208
>
192-
PNG
209+
{t("png")}
193210
</a>
194211
</div>
195212

196213
<p className="text-xs text-ep-dust">
197-
First seen:{" "}
214+
{t("firstSeen")}{" "}
198215
<a
199216
href={`https://solscan.io/account/${result.address}`}
200217
target="_blank"
201218
rel="noopener noreferrer"
202219
className="underline decoration-ep-edge underline-offset-4 hover:text-ep-dim"
203220
>
204-
epoch {result.firstEpoch} · slot{" "}
205-
{result.firstSlot.toLocaleString("en-US")}
221+
{t("firstSeenEpoch", {
222+
epoch: result.firstEpoch,
223+
slot: numberFormatter.format(result.firstSlot),
224+
})}
206225
</a>
207226
</p>
208227
</div>

apps/web/src/lib/epoch1000/validator-cache.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66

77
export const VALIDATOR_LOOKUP_CACHE_REVALIDATE_SECONDS = 60;
88

9-
const CACHE_KEY_VERSION = "epoch1000-validator-first-tx-v2";
9+
const CACHE_KEY_VERSION = "epoch1000-validator-first-tx-v3";
1010
const CACHE_TAG = "epoch1000-validator-lookup";
1111
const LOCAL_CACHE_MAX_ENTRIES = 1_000;
1212

apps/web/src/lib/epoch1000/vote-account.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,32 @@ export async function findValidatorFirstTransaction(
2525
if (res.status === 404) return null;
2626
if (!res.ok) throw new Error(`Trillium API failed: ${res.status}`);
2727

28-
const records = (await res.json()) as TrilliumValidatorReward[];
29-
const record = records.find(
28+
const payload: unknown = await res.json();
29+
if (!Array.isArray(payload)) return null;
30+
31+
const records = payload as TrilliumValidatorReward[];
32+
const matches = records.filter(
3033
(entry) => entry.vote_account_pubkey === voteAccount,
3134
);
32-
if (!record || record.sw_first_epoch_with_stake == null) return null;
35+
if (matches.length === 0) return null;
36+
37+
// Trillium returns per-epoch rows; sw_first_epoch_with_stake is only on some.
38+
const recordWithFirstEpoch = matches.find(
39+
(entry) => entry.sw_first_epoch_with_stake != null,
40+
);
41+
if (!recordWithFirstEpoch) return null;
42+
43+
const latestRecord = matches[0];
3344

3445
return {
3546
signature: "",
36-
slot: record.sw_first_epoch_with_stake * SLOTS_PER_EPOCH,
47+
slot: recordWithFirstEpoch.sw_first_epoch_with_stake * SLOTS_PER_EPOCH,
3748
blockTime: null,
3849
capped: false,
3950
scanned: 0,
40-
logoUrl: record.icon_url?.trim() || null,
51+
logoUrl:
52+
latestRecord?.icon_url?.trim() ||
53+
recordWithFirstEpoch.icon_url?.trim() ||
54+
null,
4155
};
4256
}

packages/i18n/messages/web/en/common.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7799,6 +7799,28 @@
77997799
"subtitle": "(Validator Edition)",
78007800
"description": "A live celebration of Solana mainnet reaching 1,000 epochs. Track the countdown, then check when your validator first started securing the network."
78017801
},
7802+
"checker": {
7803+
"heading": "Check <gradient>validator</gradient>",
7804+
"description": "Enter a vote account to find your first validating epoch and share your survivor card. No connect, no signature.",
7805+
"placeholder": "vote account address",
7806+
"inputAriaLabel": "Validator vote account address",
7807+
"submit": "Check",
7808+
"submitLoading": "Checking...",
7809+
"loadingChecking": "checking...",
7810+
"loadingFindingVote": "finding first vote...",
7811+
"loadingScanning": "scanning validator history...",
7812+
"loadingBuilding": "building card...",
7813+
"showVoteAccount": "Show vote account",
7814+
"share": "Share",
7815+
"copyLink": "Copy link",
7816+
"copied": "Copied",
7817+
"png": "PNG",
7818+
"firstSeen": "First seen:",
7819+
"firstSeenEpoch": "epoch {epoch} · slot {slot}",
7820+
"imageAlt": "Validator card: first seen epoch {firstEpoch}, validated {epochsSurvived} epochs",
7821+
"tweet": "I've validated {epochsSurvived}{capped} Solana epochs - {tier}.\n\n{cardUrl}",
7822+
"lookupFailed": "Lookup failed. Try again."
7823+
},
78027824
"card": {
78037825
"meta": {
78047826
"title": "I've been validating Solana for {survived} epochs",

0 commit comments

Comments
 (0)