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__factory from @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) → 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)
Quest Metadata
createChallenge already accepts optional title, description, imageURI, and metadataURI fields. The frontend currently exposes title for every quest and description for public quests; when either text field is empty, the UI keeps its generated B3MO Quest #<id> title and contextual description fallback. Contract byte limits are 120 bytes for title and 500 bytes for description.
The deployed contract has no function to update quest metadata after creation. Do not simulate creator edits in frontend-only or indexer-only storage: editable titles/descriptions require a contract upgrade with creator authorization and an update event that downstream readers can index.
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. List views retain viewerActions so progress can be shown without another contract read.
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.
resolveChallengeDetail flags (resolveChallengeDetail.ts)
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:
- Participation guide (QuestParticipationGuide.tsx): reusable in-product explanation of funding modes, winner rules, valid action windows, claims, and refunds. It replaces the former
ChallengeStepsCard while preserving the dismissible desktop/mobile flow.
- 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), and a fully empty connected-wallet view offers the existing Stake/private creation flow.
- 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/).
nextChallengeStatus.ts selects one wallet-aware state in urgency order (claim/refund/finalize, invitation/action, live progress, upcoming, fully-claimed Split Win outcome). The NextChallengeStatusCard is a homepage summary only; the Quest hub keeps the same Quest in its relevant live carousel, avoiding a duplicate card and a no-op “See all” loop. Max Actions copy reports progress without implying a guaranteed win.
The app-detail Quest launchpad is shown near the top of the page to app admins, moderators, and the configured team wallet. It exposes sponsored Quest creation, the readiness/compliance checklist, and the participation guide.
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 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, QuestParticipationGuide, CreateChallengeModal/, CompactSkeleton)
- Detail UI: app/b3mo-quests/[challengeId]/components/
- Shared UI: app/b3mo-quests/shared/ (
NextChallengeStatusCard plus shared status, badge, action, and description helpers)
- Hooks: hooks/useChallengesDeployBlock.ts (returns
0)
Contracts:
- B3TRChallenges.sol
- interfaces/IChallenges.sol
- challenges/libraries/
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
1---2name: b3mo-quests3description: 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.4---56# B3TR Challenges (Quests)78Peer-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.910## Contract & Config1112- **Contract name**: `B3TRChallenges` ([packages/contracts/contracts/B3TRChallenges.sol](packages/contracts/contracts/B3TRChallenges.sol))13- **Interface**: [packages/contracts/contracts/interfaces/IChallenges.sol](packages/contracts/contracts/interfaces/IChallenges.sol)14- **Types lib**: [packages/contracts/contracts/challenges/libraries/ChallengeTypes.sol](packages/contracts/contracts/challenges/libraries/ChallengeTypes.sol)15- **Config address**: `getConfig().challengesContractAddress`16- **Frontend ABI import**: `B3TRChallenges__factory` from `@vechain/vebetterdao-contracts/typechain-types`1718## Enums (see [types.ts](apps/frontend/src/api/challenges/types.ts))1920| Enum | Values |21|------|--------|22| `ChallengeKind` | `Stake` (0), `Sponsored` (1) |23| `ChallengeVisibility` | `Public` (0), `Private` (1) |24| `ChallengeType` | `MaxActions` (0), `SplitWin` (1) |25| `ChallengeStatus` | `Pending` (0), `Active` (1), `Completed` (2), `Cancelled` (3), `Invalid` (4) |26| `SettlementMode` | `None`, `TopWinners`, `CreatorRefund`, `SplitWinCompleted` |27| `ParticipantStatus` | `None`, `Invited`, `Declined`, `Joined` |2829`MaxActions`: capped participant pool, top scorer wins pool. `SplitWin`: uncapped, sponsored-only, first-to-reach-threshold wins a slot.3031## Events (lifecycle)3233Indexed topics in parens. All lifecycle events have `challengeId` as first indexed topic.3435| Event | Indexed user topic | Purpose |36|-------|-------------------|---------|37| `ChallengeCreated` | `creator`, `endRound` | New challenge + all metadata |38| `SplitWinConfigured` | — | Fired right after `ChallengeCreated` for Split Win only |39| `ChallengeInviteAdded` | `invitee` | Invitation to a private challenge |40| `ChallengeJoined` | `participant` | User joined |41| `ChallengeLeft` | `participant` | User left before start (Pending only) |42| `ChallengeDeclined` | `participant` | Invitee declined |43| `ChallengeCancelled` | — | Creator cancelled (Pending) |44| `ChallengeActivated` | — | Pending → Active (sync) |45| `ChallengeInvalidated` | — | Pending → Invalid (sync) |46| `ChallengeCompleted` | — | Active → Completed, carries `settlementMode`, `bestScore`, `bestCount` |47| `ChallengePayoutClaimed` | `account` | MaxActions winner claimed |48| `ChallengeRefundClaimed` | `account` | Cancelled/Invalid refund claimed |49| `SplitWinPrizeClaimed` | `winner` | SplitWin slot claimed (`prize`, `actions`, `winnersClaimed`) |50| `SplitWinCreatorRefunded` | `creator` | Creator reclaimed unclaimed slots after `endRound` |5152## Key View Functions5354- `getChallenge(id)` → `ChallengeView` struct (all scalar fields + counts)55- `getChallengeStatus(id)` → **computed** status (preferred over the struct's stored `status` — reflects time-based transitions without needing `syncChallenge` first)56- `getChallengeParticipants(id)` / `getChallengeInvited(id)` / `getChallengeDeclined(id)` / `getChallengeWinners(id)` / `getChallengeSelectedApps(id)` → `address[]` / `bytes32[]`57- `getParticipantStatus(id, account)` → `ParticipantStatus`. **After `ChallengeLeft`, returns `None`** (not a stored "Left" state) — datasource must consult the `ChallengeLeft` event to detect left users.58- `isInvitationEligible(id, account)` → can join/re-accept (even after decline)59- `isSplitWinWinner(id, account)` → in winners list60- `getParticipantActions(id, participant)` → live action count61- `maxParticipants()` → default max for MaxActions62- `minBetAmount()` → minimum stake (wei)6364## Quest Metadata6566`createChallenge` already accepts optional `title`, `description`, `imageURI`, and `metadataURI` fields. The frontend currently exposes title for every quest and description for public quests; when either text field is empty, the UI keeps its generated `B3MO Quest #<id>` title and contextual description fallback. Contract byte limits are 120 bytes for title and 500 bytes for description.6768The deployed contract has no function to update quest metadata after creation. Do not simulate creator edits in frontend-only or indexer-only storage: editable titles/descriptions require a contract upgrade with creator authorization and an update event that downstream readers can index.6970## Frontend Architecture7172```mermaid73graph TD74 UI["ChallengesPageContent / ChallengeDetailPageContent"]75 UI --> SectionHooks["use{Needed,User,OpenToJoin,WhatOthers,History}Section"]76 UI --> DetailHook["useChallengeDetail"]77 SectionHooks --> Indexer["api/indexer/challenges/ (fetchWalletChallenges / fetchPublicChallenges)"]78 SectionHooks --> Multicall["buildChallengeViews (contract multicall)"]79 SectionHooks --> Claim["fetchViewerClaimState (1 event scan)"]80 SectionHooks --> MaxP["fetchMaxParticipants (contract)"]81 Multicall --> Resolve["resolveChallengeDetail → canX flags"]82 DetailHook --> BuildDetail["buildChallengeDetail (contract + event scans)"]83```8485**Lists (sections + history) use the indexer. Detail is still event-based.** Split:8687- **Transport** ([api/indexer/challenges/](apps/frontend/src/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.88- **Orchestration** ([api/challenges/](apps/frontend/src/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.8990Pagination: `CHALLENGES_PAGE_SIZE = 12`. Indexer `data[].challengeId` drives the multicall; `pagination.hasNext` drives `useInfiniteQuery.getNextPageParam`.9192### Event scans still live9394- `fetchChallengeEvents` caches per-event+filter via `queryClient.fetchQuery` (30s stale)95- `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).96- `fetchChallengeClaimedBy` scans per-challenge claim events for the detail view's `claimedBy` / `refundedBy` / `creatorRefunded` arrays9798### Build helpers (contract multicall)99100- [`buildChallengeView.ts`](apps/frontend/src/api/challenges/buildChallengeView.ts): multicall `getChallenge + getChallengeStatus + getParticipantStatus + isInvitationEligible + isSplitWinWinner + getParticipantActions` per id, then `resolveChallengeDetail` → `ChallengeView`. List views retain `viewerActions` so progress can be shown without another contract read.101- [`buildChallengeDetail.ts`](apps/frontend/src/api/challenges/buildChallengeDetail.ts): same + `getChallengeParticipants/Invited/Declined/SelectedApps/Winners` + `fetchChallengeClaimedBy` + `ChallengeCreated` event lookup for `createdAt`102103### Wei/Ether boundary104105**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).106107## Section Definitions108109Each 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.110111| Section | Viewer present → fetcher | Guest fallback |112|---------|--------------------------|----------------|113| Action needed | `fetchWalletChallenges(viewer, "NeededAction")` | — (viewer-only) |114| Your Challenges | `fetchWalletChallenges(viewer, "MyChallenges")` | — (viewer-only) |115| Open to Join | `fetchWalletChallenges(viewer, "OpenToJoin")` | `fetchPublicChallenges("Pending")` |116| What Others Are Doing | `fetchWalletChallenges(viewer, "OthersActive")` + frontend drops Active past `endRound` | `fetchPublicChallenges("Active")` + frontend drops Active past `endRound` |117| History | `fetchWalletChallenges(viewer, "History")` | — (viewer-only) |118119`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.120121## resolveChallengeDetail flags ([resolveChallengeDetail.ts](apps/frontend/src/api/challenges/resolveChallengeDetail.ts))122123Pure function from raw state → per-viewer `canX` booleans. Key rules:124125- `canJoin`: `Pending && Public && !joined && !creator && !atParticipantLimit`126- `canAccept`: `Pending && (invited \|\| eligibleInvitee) && !atLimit` (also true for declined-but-eligible)127- `canComplete`: MaxActions + `Active` + `endRound < currentRound` + (isCreator \|\| isJoined)128- `canClaim`: MaxActions + `Completed` + joined + `participantActions === bestScore` (or isCreator for `CreatorRefund` settlement)129- `canClaimSplitWin`: SplitWin + `Active` + joined + inWindow + `participantActions >= threshold` + slots left130- `canClaimCreatorSplitWinRefund`: SplitWin + creator + `currentRound > endRound` + slots left + `!creatorRefunded`131- `canRefund`: `Cancelled\|Invalid` + (Stake ? joined : creator) + not already refunded132- `isActionable`: any `canX` OR pending invite acceptable133- `isHistorical`: `(declined && canAccept)` OR `(terminal && (creator \|\| joined) && !needsPastAction)`134- `isParticipating`: `(Pending\|Active)` + not awaiting completion + (creator \|\| joined)135- `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.136137## UI Structure138139[ChallengesPageContent](apps/frontend/src/app/b3mo-quests/components/ChallengesPageContent.tsx) is a 2-tab shell:140141- **Participation guide** ([QuestParticipationGuide.tsx](apps/frontend/src/app/b3mo-quests/components/QuestParticipationGuide.tsx)): reusable in-product explanation of funding modes, winner rules, valid action windows, claims, and refunds. It replaces the former `ChallengeStepsCard` while preserving the dismissible desktop/mobile flow.142- **Current** ([CurrentTab.tsx](apps/frontend/src/app/b3mo-quests/components/CurrentTab.tsx)): 4 `SectionCarousel`s (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`), and a fully empty connected-wallet view offers the existing Stake/private creation flow.143- **History** ([HistoryTab.tsx](apps/frontend/src/app/b3mo-quests/components/HistoryTab.tsx)): `ChallengesGrid` infinite scroll, items deduped by `challengeId`. **No filters** (sections already segment meaningfully; filters added noise).144145`SectionCarousel` uses Swiper with `onReachEnd` → auto `fetchNextPage`, skeleton slides while `isFetchingNextPage`. `ChallengesGrid` uses an IntersectionObserver sentinel + skeleton grid cards.146147`ChallengeCard` action buttons come from `canX` flags; detail page uses the same flags (`ChallengeActionsRow`, modals under [`[challengeId]/components/`](apps/frontend/src/app/b3mo-quests/[challengeId]/components/)).148149`nextChallengeStatus.ts` selects one wallet-aware state in urgency order (claim/refund/finalize, invitation/action, live progress, upcoming, fully-claimed Split Win outcome). The `NextChallengeStatusCard` is a homepage summary only; the Quest hub keeps the same Quest in its relevant live carousel, avoiding a duplicate card and a no-op “See all” loop. Max Actions copy reports progress without implying a guaranteed win.150151The app-detail Quest launchpad is shown near the top of the page to app admins, moderators, and the configured team wallet. It exposes sponsored Quest creation, the readiness/compliance checklist, and the participation guide.152153## Section Hooks ([useChallengeSections.ts](apps/frontend/src/api/challenges/useChallengeSections.ts))154155One `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`](apps/frontend/src/api/challenges/useChallengeDetail.ts) with key `["challenges", "detail", id, viewer]` — calls `buildChallengeDetail` directly (no datasource indirection).156157## Write path ([useChallengeActions.ts](apps/frontend/src/api/challenges/useChallengeActions.ts))158159Builds multi-clause txs (approve+action for stake joins, leave+decline for invited users). After success:1601611. Invalidates `["challenges"]`, account B3TR balance, and claim-event keys broadly1622. Actively refetches `["challenges", "section"]` + `["challenges", "detail"]`1633. Schedules follow-up refetches at 1.5s / 4s / 8s to catch the tx being indexed in new events164165**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`.166167**Query-key prefix convention**: every challenges-related cache key starts with `["challenges", ...]` so the broad invalidation always hits.168169## Key Files Reference170171Frontend:172- Transport (indexer): [api/indexer/challenges/](apps/frontend/src/api/indexer/challenges/) — `fetchWalletChallenges`, `fetchPublicChallenges`, hand-typed response types (swap for schema-derived once `yarn generate:schema` picks up the new endpoints)173- Orchestration + domain: [api/challenges/](apps/frontend/src/api/challenges/) — `useChallengeSections.ts`, `useChallengeDetail.ts`, `useChallengeActions.ts`, `resolveChallengeDetail.ts`, `types.ts`, `buildChallengeView.ts`, `buildChallengeDetail.ts`, `claimState.ts`, `fetchChallengeEvents.ts`, `fetchMaxParticipants.ts`174- Hub UI: [app/b3mo-quests/components/](apps/frontend/src/app/b3mo-quests/components/) (ChallengesPageContent, CurrentTab, HistoryTab, SectionCarousel, ChallengeCard, ChallengesGrid, ChallengeFilters, QuestParticipationGuide, CreateChallengeModal/, CompactSkeleton)175- Detail UI: [app/b3mo-quests/[challengeId]/components/](apps/frontend/src/app/b3mo-quests/[challengeId]/components/)176- Shared UI: [app/b3mo-quests/shared/](apps/frontend/src/app/b3mo-quests/shared/) (`NextChallengeStatusCard` plus shared status, badge, action, and description helpers)177- Hooks: [hooks/useChallengesDeployBlock.ts](apps/frontend/src/hooks/useChallengesDeployBlock.ts) (returns `0`)178179Contracts:180- [B3TRChallenges.sol](packages/contracts/contracts/B3TRChallenges.sol)181- [interfaces/IChallenges.sol](packages/contracts/contracts/interfaces/IChallenges.sol)182- [challenges/libraries/](packages/contracts/contracts/challenges/libraries/)183184## Indexer integration status185186- **Lists**: live on indexer (`/b3tr/challenges` + `/b3tr/users/{wallet}/challenges?filter=...`).187- **Detail**: still event-based — `/b3tr/challenges/{id}` endpoint exists but freshness right after a tx isn't guaranteed. Migrate later if desired.188- **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.189- **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.190191## Conventions192193- Translation keys for user-facing text; add via the `translate` skill across all 17 languages194- Status labels: `challengeStatusLabel(status)` in types.ts; don't format manually195- `humanNumber()` / `getCompactFormatter()` from `@repo/utils/FormattingUtils` for B3TR displays (ether strings in, formatted string out)196- i18n label "Quests" replaces "Challenges" in all user-facing copy; code still uses "challenge"; route is `/b3mo-quests`