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
- Contract name:
B3TRChallenges(packages/contracts/contracts/B3TRChallenges.sol) - Interface: packages/contracts/contracts/interfaces/IChallenges.sol
- Types lib: packages/contracts/contracts/challenges/libraries/ChallengeTypes.sol
- Config address:
getConfig().challengesContractAddress - Frontend ABI import:
B3TRChallenges__factoryfrom@vechain/vebetterdao-contracts/typechain-types
Enums (see types.ts)
| 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)→ChallengeViewstruct (all scalar fields + counts)getChallengeStatus(id)→ computed status (preferred over the struct's storedstatus— reflects time-based transitions without needingsyncChallengefirst)getChallengeParticipants(id)/getChallengeInvited(id)/getChallengeDeclined(id)/getChallengeWinners(id)/getChallengeSelectedApps(id)→address[]/bytes32[]getParticipantStatus(id, account)→ParticipantStatus. AfterChallengeLeft, returnsNone(not a stored "Left" state) — datasource must consult theChallengeLeftevent to detect left users.isInvitationEligible(id, account)→ can join/re-accept (even after decline)isSplitWinWinner(id, account)→ in winners listgetParticipantActions(id, participant)→ live action countmaxParticipants()→ default max for MaxActionsminBetAmount()→ 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) andGET /api/v1/b3tr/users/{wallet}/challenges?filter=...(wallet-scoped). 5ChallengeFiltervalues 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-viewercanXflags.
Pagination: CHALLENGES_PAGE_SIZE = 12. Indexer data[].challengeId drives the multicall; pagination.hasNext drives useInfiniteQuery.getNextPageParam.
Event scans still live
fetchChallengeEventscaches per-event+filter viaqueryClient.fetchQuery(30s stale)fetchViewerClaimStatescans 4 viewer-scoped claim events → sets of ids where the viewer already claimed. Still used becauseChallengeSummaryResponsedoesn't expose per-viewer claim flags yet (tracked inB3trUserChallenge.hasClaimedPrize/Refundserver-side; follow-up: expose on the wire).fetchChallengeClaimedByscans per-challenge claim events for the detail view'sclaimedBy/refundedBy/creatorRefundedarrays
Build helpers (contract multicall)
buildChallengeView.ts: multicallgetChallenge + getChallengeStatus + getParticipantStatus + isInvitationEligible + isSplitWinWinner + getParticipantActionsper id, thenresolveChallengeDetail→ChallengeViewbuildChallengeDetail.ts: same +getChallengeParticipants/Invited/Declined/SelectedApps/Winners+fetchChallengeClaimedBy+ChallengeCreatedevent lookup forcreatedAt
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.
resolveChallengeDetail flags (resolveChallengeDetail.ts)
Pure function from raw state → per-viewer canX booleans. Key rules:
canJoin:Pending && Public && !joined && !creator && !atParticipantLimitcanAccept: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 forCreatorRefundsettlement)canClaimSplitWin: SplitWin +Active+ joined + inWindow +participantActions >= threshold+ slots leftcanClaimCreatorSplitWinRefund: SplitWin + creator +currentRound > endRound+ slots left +!creatorRefundedcanRefund:Cancelled\|Invalid+ (Stake ? joined : creator) + not already refundedisActionable: anycanXOR pending invite acceptableisHistorical:(declined && canAccept)OR(terminal && (creator \|\| joined) && !needsPastAction)isParticipating:(Pending\|Active)+ not awaiting completion + (creator || joined)wasInvited: equalsisInvitationEligible— 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 viaSectionCarousel'sitemsoverride prop. Empty sections auto-hide (hideWhenEmpty). - History (HistoryTab.tsx):
ChallengesGridinfinite scroll, items deduped bychallengeId. 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/).
Section Hooks (useChallengeSections.ts)
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).
Write path (useChallengeActions.ts)
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 onceyarn generate:schemapicks 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 alsoparticipantStatus,bestScore,maxParticipants) onChallengeSummaryResponseso we can drop the lastfetchViewerClaimStateevent scan. - Schema: hand-typed in
api/indexer/challenges/types.ts; swap forpaths-derived types afteryarn generate:schemapicks up the new endpoints on an updated indexer.
Conventions
- Translation keys for user-facing text; add via the
translateskill across all 17 languages - Status labels:
challengeStatusLabel(status)in types.ts; don't format manually humanNumber()/getCompactFormatter()from@repo/utils/FormattingUtilsfor 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