| name | b3mo-quests |
| description | Complete domain knowledge for B3TR Challenges (branded "Quests" in UI, route `/b3mo-quests`) on VeBetterDAO. Covers the on-chain contract, frontend datasource architecture, event-based data flow, UI structure (Current/History tabs), section hooks, resolveChallengeDetail logic, claim/refund flows, and wei/ether formatting boundaries. Use when working on anything under apps/frontend/src/app/b3mo-quests/, apps/frontend/src/api/challenges/, or packages/contracts/contracts/B3TRChallenges* / challenges/** / interfaces/IChallenges.sol. Triggers on challenge, quest, quests page, b3mo-quests, B3TRChallenges, SplitWin, MaxActions, challenge invite, claim prize, claim refund, challenge datasource, challenge section, challenge carousel, left challenge, declined challenge. |
B3TR Challenges (Quests)
Peer-to-peer or sponsored competitions where users complete X2Earn app actions to win B3TR prizes. Branded as "Quests" in the UI (route /b3mo-quests), referred to as "challenges" in code.
Contract & Config
| Enum | Values |
|---|
ChallengeKind | Stake (0), Sponsored (1) |
ChallengeVisibility | Public (0), Private (1) |
ChallengeType | MaxActions (0), SplitWin (1) |
ChallengeStatus | Pending (0), Active (1), Completed (2), Cancelled (3), Invalid (4) |
SettlementMode | None, TopWinners, CreatorRefund, SplitWinCompleted |
ParticipantStatus | None, Invited, Declined, Joined |
MaxActions: capped participant pool, top scorer wins pool. SplitWin: uncapped, sponsored-only, first-to-reach-threshold wins a slot.
Events (lifecycle)
Indexed topics in parens. All lifecycle events have challengeId as first indexed topic.
| Event | Indexed user topic | Purpose |
|---|
ChallengeCreated | creator, endRound | New challenge + all metadata |
SplitWinConfigured | — | Fired right after ChallengeCreated for Split Win only |
ChallengeInviteAdded | invitee | Invitation to a private challenge |
ChallengeJoined | participant | User joined |
ChallengeLeft | participant | User left before start (Pending only) |
ChallengeDeclined | participant | Invitee declined |
ChallengeCancelled | — | Creator cancelled (Pending) |
ChallengeActivated | — | Pending → Active (sync) |
ChallengeInvalidated | — | Pending → Invalid (sync) |
ChallengeCompleted | — | Active → Completed, carries settlementMode, bestScore, bestCount |
ChallengePayoutClaimed | account | MaxActions winner claimed |
ChallengeRefundClaimed | account | Cancelled/Invalid refund claimed |
SplitWinPrizeClaimed | winner | SplitWin slot claimed (prize, actions, winnersClaimed) |
SplitWinCreatorRefunded | creator | Creator reclaimed unclaimed slots after endRound |
Key View Functions
getChallenge(id) → ChallengeView struct (all scalar fields + counts)
getChallengeStatus(id) → computed status (preferred over the struct's stored status — reflects time-based transitions without needing syncChallenge first)
getChallengeParticipants(id) / getChallengeInvited(id) / getChallengeDeclined(id) / getChallengeWinners(id) / getChallengeSelectedApps(id) → address[] / bytes32[]
getParticipantStatus(id, account) → ParticipantStatus. After ChallengeLeft, returns None (not a stored "Left" state) — datasource must consult the ChallengeLeft event to detect left users.
isInvitationEligible(id, account) → can join/re-accept (even after decline)
isSplitWinWinner(id, account) → in winners list
getParticipantActions(id, participant) → live action count
maxParticipants() → default max for MaxActions
minBetAmount() → minimum stake (wei)
Frontend Architecture
graph TD
UI["ChallengesPageContent / ChallengeDetailPageContent"]
UI --> SectionHooks["use{Needed,User,OpenToJoin,WhatOthers,History}Section"]
UI --> DetailHook["useChallengeDetail"]
SectionHooks --> Indexer["api/indexer/challenges/ (fetchWalletChallenges / fetchPublicChallenges)"]
SectionHooks --> Multicall["buildChallengeViews (contract multicall)"]
SectionHooks --> Claim["fetchViewerClaimState (1 event scan)"]
SectionHooks --> MaxP["fetchMaxParticipants (contract)"]
Multicall --> Resolve["resolveChallengeDetail → canX flags"]
DetailHook --> BuildDetail["buildChallengeDetail (contract + event scans)"]
Lists (sections + history) use the indexer. Detail is still event-based. Split:
- Transport (api/indexer/challenges/): thin HTTP fetchers for
GET /api/v1/b3tr/challenges (public, status-filtered) and GET /api/v1/b3tr/users/{wallet}/challenges?filter=... (wallet-scoped). 5 ChallengeFilter values map 1:1 to the 5 UI sections.
- Orchestration (api/challenges/): each section hook composes the indexer fetcher +
buildChallengeViews (contract multicall) inline — the indexer gives IDs + createdAt, the multicall enriches with per-viewer canX flags.
Pagination: CHALLENGES_PAGE_SIZE = 12. Indexer data[].challengeId drives the multicall; pagination.hasNext drives useInfiniteQuery.getNextPageParam.
Event scans still live
fetchChallengeEvents caches per-event+filter via queryClient.fetchQuery (30s stale)
fetchViewerClaimState scans 4 viewer-scoped claim events → sets of ids where the viewer already claimed. Still used because ChallengeSummaryResponse doesn't expose per-viewer claim flags yet (tracked in B3trUserChallenge.hasClaimedPrize/Refund server-side; follow-up: expose on the wire).
fetchChallengeClaimedBy scans per-challenge claim events for the detail view's claimedBy / refundedBy / creatorRefunded arrays
Build helpers (contract multicall)
buildChallengeView.ts: multicall getChallenge + getChallengeStatus + getParticipantStatus + isInvitationEligible + isSplitWinWinner + getParticipantActions per id, then resolveChallengeDetail → ChallengeView
buildChallengeDetail.ts: same + getChallengeParticipants/Invited/Declined/SelectedApps/Winners + fetchChallengeClaimedBy + ChallengeCreated event lookup for createdAt
Wei/Ether boundary
Critical: raw contract values (stakeAmount, totalPrize, prizePerWinner) are uint256 wei. UI expects ether strings. buildChallengeView and buildChallengeDetail convert with formatEther(). threshold and bestScore are action counts, not B3TR — never format with formatEther. The indexer already returns wei-stripped ether strings for these fields but we discard them (multicall values win to keep freshness after a tx).
Section Definitions
Each section hook picks a fetcher + filter. The indexer's ChallengeFilter (see packages/common/src/main/kotlin/org/vechain/indexer/b3tr/challenges/ChallengeFilter.kt in the indexer repo) encodes the same predicates we previously applied client-side, so no post-fetch .filter() is needed.
| Section | Viewer present → fetcher | Guest fallback |
|---|
| Action needed | fetchWalletChallenges(viewer, "NeededAction") | — (viewer-only) |
| Your Challenges | fetchWalletChallenges(viewer, "MyChallenges") | — (viewer-only) |
| Open to Join | fetchWalletChallenges(viewer, "OpenToJoin") | fetchPublicChallenges("Pending") |
| What Others Are Doing | fetchWalletChallenges(viewer, "OthersActive") + frontend drops Active past endRound | fetchPublicChallenges("Active") + frontend drops Active past endRound |
| History | fetchWalletChallenges(viewer, "History") | — (viewer-only) |
NeededAction bucket on the server already covers outstanding invites + claimable + finalizable + reclaimable. History covers two cases the user perceives as "no longer current": (1) terminal-state (Completed/Cancelled/Invalid) challenges the wallet was involved in, plus (2) still-live (Pending/Active) challenges the wallet has actively bowed out of — declined invitations and joined-then-left participants (detected as non-creator records with participantStatus ∈ {None, Declined}). Re-accept / re-join works straight from the History card via the existing canAccept / canJoin flags. The UI CurrentTab still dedupes across sections in render order; History is its own tab so a left-Pending-Public challenge can appear in both History and OpenToJoin.
Pure function from raw state → per-viewer canX booleans. Key rules:
canJoin: Pending && Public && !joined && !creator && !atParticipantLimit
canAccept: Pending && (invited \|\| eligibleInvitee) && !atLimit (also true for declined-but-eligible)
canComplete: MaxActions + Active + endRound < currentRound + (isCreator || isJoined)
canClaim: MaxActions + Completed + joined + participantActions === bestScore (or isCreator for CreatorRefund settlement)
canClaimSplitWin: SplitWin + Active + joined + inWindow + participantActions >= threshold + slots left
canClaimCreatorSplitWinRefund: SplitWin + creator + currentRound > endRound + slots left + !creatorRefunded
canRefund: Cancelled\|Invalid + (Stake ? joined : creator) + not already refunded
isActionable: any canX OR pending invite acceptable
isHistorical: (declined && canAccept) OR (terminal && (creator \|\| joined) && !needsPastAction)
isParticipating: (Pending\|Active) + not awaiting completion + (creator || joined)
wasInvited: equals isInvitationEligible — true iff the viewer was ever in the invited pool (flag never gets cleared on-chain). Used by the leave action to decide whether to chain a decline clause.
UI Structure
ChallengesPageContent is a 2-tab shell:
- Current (CurrentTab.tsx): 4
SectionCarousels (Action needed, Your Challenges, Open to Join, What Others Are Doing). Cross-section dedup at the UI layer: items are assigned to the first matching section in render order; later sections drop duplicates via SectionCarousel's items override prop. Empty sections auto-hide (hideWhenEmpty).
- History (HistoryTab.tsx):
ChallengesGrid infinite scroll, items deduped by challengeId. No filters (sections already segment meaningfully; filters added noise).
SectionCarousel uses Swiper with onReachEnd → auto fetchNextPage, skeleton slides while isFetchingNextPage. ChallengesGrid uses an IntersectionObserver sentinel + skeleton grid cards.
ChallengeCard action buttons come from canX flags; detail page uses the same flags (ChallengeActionsRow, modals under [challengeId]/components/).
One useInfiniteQuery per section with stable query keys under ["challenges", "section", sectionId, viewer]. Each hook's queryFn = indexer fetch → buildSectionPage (claim-state + maxParticipants + buildChallengeViews). Detail via useChallengeDetail with key ["challenges", "detail", id, viewer] — calls buildChallengeDetail directly (no datasource indirection).
Builds multi-clause txs (approve+action for stake joins, leave+decline for invited users). After success:
- Invalidates
["challenges"], account B3TR balance, and claim-event keys broadly
- Actively refetches
["challenges", "section"] + ["challenges", "detail"]
- Schedules follow-up refetches at 1.5s / 4s / 8s to catch the tx being indexed in new events
Leave + decline chaining: leaveChallenge(challenge) takes a ChallengeView; if challenge.wasInvited is true it appends a declineChallenge clause to the same tx. Without this the contract re-adds the user to the invited pool on leave (because invitationEligible is never cleared), so they'd land back as "invited" instead of fully opting out. The decline clause is omitted when the user was never invited — declineChallenge would revert with NotInvited.
Query-key prefix convention: every challenges-related cache key starts with ["challenges", ...] so the broad invalidation always hits.
Key Files Reference
Frontend:
- Transport (indexer): api/indexer/challenges/ —
fetchWalletChallenges, fetchPublicChallenges, hand-typed response types (swap for schema-derived once yarn generate:schema picks up the new endpoints)
- Orchestration + domain: api/challenges/ —
useChallengeSections.ts, useChallengeDetail.ts, useChallengeActions.ts, resolveChallengeDetail.ts, types.ts, buildChallengeView.ts, buildChallengeDetail.ts, claimState.ts, fetchChallengeEvents.ts, fetchMaxParticipants.ts
- Hub UI: app/b3mo-quests/components/ (ChallengesPageContent, CurrentTab, HistoryTab, SectionCarousel, ChallengeCard, ChallengesGrid, ChallengeFilters, ChallengeStepsCard, CreateChallengeModal/, CompactSkeleton)
- Detail UI: app/b3mo-quests/[challengeId]/components/
- Shared UI: app/b3mo-quests/shared/
- Hooks: hooks/useChallengesDeployBlock.ts (returns
0)
Contracts:
Indexer integration status
- Lists: live on indexer (
/b3tr/challenges + /b3tr/users/{wallet}/challenges?filter=...).
- Detail: still event-based —
/b3tr/challenges/{id} endpoint exists but freshness right after a tx isn't guaranteed. Migrate later if desired.
- Follow-up: ask the indexer team to expose
hasClaimedPrize / hasClaimedRefund (ideally also participantStatus, bestScore, maxParticipants) on ChallengeSummaryResponse so we can drop the last fetchViewerClaimState event scan.
- Schema: hand-typed in
api/indexer/challenges/types.ts; swap for paths-derived types after yarn generate:schema picks up the new endpoints on an updated indexer.
Conventions
- Translation keys for user-facing text; add via the
translate skill across all 17 languages
- Status labels:
challengeStatusLabel(status) in types.ts; don't format manually
humanNumber() / getCompactFormatter() from @repo/utils/FormattingUtils for B3TR displays (ether strings in, formatted string out)
- i18n label "Quests" replaces "Challenges" in all user-facing copy; code still uses "challenge"; route is
/b3mo-quests