Skip to content

fetchLeaderboardFromEvents hardcodes boostUtilization to 0 in the event-scan fallback path, unlike the backend-API path — wrong data exactly when the resilience fallback is needed #142

Description

@prodbycorne

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

  • fetchLeaderboardFromEvents's output for boostUtilization is either a genuinely-computed value or an explicit "unavailable" sentinel distinguishable from a real 0.
  • LeaderboardPage's "Boost %" column renders differently for "unavailable" vs. a real 0%, if the sentinel approach is chosen.
  • A test exercises the event-scan fallback path specifically (API URL unset or API call mocked to fail) and asserts the leaderboard's boost column reflects the chosen fix, not a silent 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) — 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

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official CampaignOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Third CampaignCampaign: Third CampaignbugSomething isn't workingleaderboardLeaderboard feature — data, sorting, paginationvery hardExtremely hard — deep expertise, careful design, and significant time required

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions