Overview
SorobanService.getLeaderboard (src/lib/soroban.ts:1426-1439) has two data sources: a backend indexer API (preferred, when NEXT_PUBLIC_LEADERBOARD_API_URL is set) and an on-chain event-scan fallback, explicitly described as resilience: "Prefer the backend indexer... otherwise derives it from on-chain events", with the API path wrapped in a try/catch that falls back on any failure: catch (err) { console.warn('[SmartDrop] leaderboard API failed, falling back to event scan:', err); } return this.fetchLeaderboardFromEvents(...).
The two paths do not compute boostUtilization consistently. fetchLeaderboardFromApi reads a real value from the backend response:
// soroban.ts:1458-1463
const entries: LeaderboardRow[] = (data.entries ?? []).map((e) => ({
address: String(e.address ?? ''),
totalCredits: Number(e.totalCredits ?? 0),
totalStake: Number(e.totalStake ?? 0),
boostUtilization: Number(e.boostUtilization ?? 0), // real value from the indexer
}));
fetchLeaderboardFromEvents computes totalCredits/totalStake by genuinely aggregating on-chain lock_assets/unlock_assets/update_credits events per address — but hardcodes boostUtilization to a constant 0 for every single row, with no aggregation attempted at all:
// soroban.ts:1521-1527
const all: LeaderboardRow[] = Array.from(agg.entries())
.map(([address, { stake, credits }]) => ({
address,
totalCredits: Math.round(credits),
totalStake: Math.round(stake),
boostUtilization: 0, // never computed from any event data
}))
Because this fallback specifically activates when the primary, presumably-more-complete backend indexer is unavailable — the exact scenario the fallback exists to keep the leaderboard usable through — every user sees a "Boost %" column (leaderboard/page.tsx:308-310, rendered for every row) that is silently, uniformly wrong (always 0%) precisely during backend outages, with no visual distinction from a row whose boost utilization is genuinely zero. totalCredits/totalStake degrade gracefully in this fallback (still computed from real event data); boostUtilization does not degrade — it's simply absent, disguised as a real, meaningful zero value.
Requirements
- Either compute
boostUtilization from on-chain data in fetchLeaderboardFromEvents (e.g. by including the boost-relevant event/topic in the same event scan, if the contract emits one), or, if that data genuinely isn't derivable from events alone, mark the field as unavailable in a way the UI can distinguish from a real zero (e.g. boostUtilization: null instead of 0, with the leaderboard table rendering "—" for null rather than "0%").
- Whichever approach is chosen, a user should not be able to mistake "boost data unavailable in the current (fallback) data source" for "this farmer has 0% boost utilization."
Acceptance Criteria
Additional Notes
More precise references
src/lib/soroban.ts:1426-1439 (getLeaderboard) — confirmed the try/catch fallback structure and its own log message ("falling back to event scan").
src/lib/soroban.ts:1441-1465 (fetchLeaderboardFromApi) — confirmed boostUtilization: Number(e.boostUtilization ?? 0) reads a real per-entry field from the backend response.
src/lib/soroban.ts:1467-1541 (fetchLeaderboardFromEvents), specifically lines 1479-1493 (the event filter, which subscribes to lock_assets/unlock_assets/update_credits topics only — no boost-related topic) and lines 1521-1528 (the hardcoded boostUtilization: 0).
src/app/leaderboard/page.tsx:254,308-310 — confirmed the "Boost %" column header and per-row {entry.boostUtilization}% render unconditionally, with no branch for a missing/unavailable value today (consistent with the field always being a plain number, never null, in the current LeaderboardEntry/LeaderboardRow types).
Additional edge cases
Implementation sketch
If boost allocation is emitted as its own on-chain event (e.g. a set_boost/update_credits-adjacent topic carrying boost_allocation or similar — confirm the actual pool contract's event shape before implementing), extend the existing event filter/topics array (soroban.ts:1479-1493) to include it and aggregate it into agg's per-address record alongside stake/credits. If no such event exists and boost data genuinely can't be derived from events:
export interface LeaderboardRow {
address: string;
totalCredits: number;
totalStake: number;
boostUtilization: number | null; // null = unavailable in this data source
}
// fetchLeaderboardFromEvents:
.map(([address, { stake, credits }]) => ({ address, totalCredits: Math.round(credits), totalStake: Math.round(stake), boostUtilization: null }))
// leaderboard/page.tsx:
<Td ...>{entry.boostUtilization != null ? `${entry.boostUtilization}%` : "—"}</Td>
Test/reproduction plan
- Unset
NEXT_PUBLIC_LEADERBOARD_API_URL (or mock fetchLeaderboardFromApi to reject), mock RPC events including at least one update_credits/boost-relevant event for a known address; call getLeaderboard; assert the returned row's boostUtilization reflects the fix (either a real computed value, or the explicit unavailable sentinel) rather than a bare 0.
- Render
LeaderboardPage with a mocked hook result containing boostUtilization: null; assert the "Boost %" cell renders "—" rather than "0%".
Cross-references
Overview
SorobanService.getLeaderboard(src/lib/soroban.ts:1426-1439) has two data sources: a backend indexer API (preferred, whenNEXT_PUBLIC_LEADERBOARD_API_URLis set) and an on-chain event-scan fallback, explicitly described as resilience: "Prefer the backend indexer... otherwise derives it from on-chain events", with the API path wrapped in a try/catch that falls back on any failure:catch (err) { console.warn('[SmartDrop] leaderboard API failed, falling back to event scan:', err); } return this.fetchLeaderboardFromEvents(...).The two paths do not compute
boostUtilizationconsistently.fetchLeaderboardFromApireads a real value from the backend response:fetchLeaderboardFromEventscomputestotalCredits/totalStakeby genuinely aggregating on-chainlock_assets/unlock_assets/update_creditsevents per address — but hardcodesboostUtilizationto a constant0for every single row, with no aggregation attempted at all:Because this fallback specifically activates when the primary, presumably-more-complete backend indexer is unavailable — the exact scenario the fallback exists to keep the leaderboard usable through — every user sees a "Boost %" column (
leaderboard/page.tsx:308-310, rendered for every row) that is silently, uniformly wrong (always0%) precisely during backend outages, with no visual distinction from a row whose boost utilization is genuinely zero.totalCredits/totalStakedegrade gracefully in this fallback (still computed from real event data);boostUtilizationdoes not degrade — it's simply absent, disguised as a real, meaningful zero value.Requirements
boostUtilizationfrom on-chain data infetchLeaderboardFromEvents(e.g. by including the boost-relevant event/topic in the same event scan, if the contract emits one), or, if that data genuinely isn't derivable from events alone, mark the field as unavailable in a way the UI can distinguish from a real zero (e.g.boostUtilization: nullinstead of0, with the leaderboard table rendering "—" fornullrather than "0%").Acceptance Criteria
fetchLeaderboardFromEvents's output forboostUtilizationis either a genuinely-computed value or an explicit "unavailable" sentinel distinguishable from a real0.LeaderboardPage's "Boost %" column renders differently for "unavailable" vs. a real0%, if the sentinel approach is chosen.0.Additional Notes
More precise references
src/lib/soroban.ts:1426-1439(getLeaderboard) — confirmed the try/catch fallback structure and its own log message ("falling back to event scan").src/lib/soroban.ts:1441-1465(fetchLeaderboardFromApi) — confirmedboostUtilization: Number(e.boostUtilization ?? 0)reads a real per-entry field from the backend response.src/lib/soroban.ts:1467-1541(fetchLeaderboardFromEvents), specifically lines 1479-1493 (the event filter, which subscribes tolock_assets/unlock_assets/update_creditstopics only — no boost-related topic) and lines 1521-1528 (the hardcodedboostUtilization: 0).src/app/leaderboard/page.tsx:254,308-310— confirmed the "Boost %" column header and per-row{entry.boostUtilization}%render unconditionally, with no branch for a missing/unavailable value today (consistent with the field always being a plain number, nevernull, in the currentLeaderboardEntry/LeaderboardRowtypes).Additional edge cases
LeaderboardRow/LeaderboardEntry(soroban.ts:51-56,useLeaderboard.ts:6-11) currently typeboostUtilization: number— introducing anull/undefinedsentinel requires a type change threaded through both files and the rendering component, not just the computation site.0%for every farmer with a real, non-zero boost allocation, the instant the backend indexer has any outage — worth fixing proactively rather than waiting for boost UI to ship and rediscovering this as a live-data bug then.Implementation sketch
If boost allocation is emitted as its own on-chain event (e.g. a
set_boost/update_credits-adjacent topic carryingboost_allocationor similar — confirm the actual pool contract's event shape before implementing), extend the existing event filter/topics array (soroban.ts:1479-1493) to include it and aggregate it intoagg's per-address record alongsidestake/credits. If no such event exists and boost data genuinely can't be derived from events:Test/reproduction plan
NEXT_PUBLIC_LEADERBOARD_API_URL(or mockfetchLeaderboardFromApito reject), mock RPC events including at least oneupdate_credits/boost-relevant event for a known address; callgetLeaderboard; assert the returned row'sboostUtilizationreflects the fix (either a real computed value, or the explicit unavailable sentinel) rather than a bare0.LeaderboardPagewith a mocked hook result containingboostUtilization: null; assert the "Boost %" cell renders "—" rather than "0%".Cross-references