Skip to content

feat: in game shop - #9982

Open
davidejensen wants to merge 5 commits into
devfrom
feat/in-game-shop
Open

feat: in game shop#9982
davidejensen wants to merge 5 commits into
devfrom
feat/in-game-shop

Conversation

@davidejensen

Copy link
Copy Markdown
Member

Pull Request Description

What does this PR change?

Test Instructions

Steps (standard run):

metaforge explorer run XXXX  # ← replace with this PR number

Expected result:

Steps (fresh account):

metaforge account create --clear
metaforge explorer run XXXX  # ← replace with this PR number

Expected result:

Automation (if applicable):

metaforge explorer test XXXX

Prerequisites

  • List any required setup steps
  • Include environment/configuration requirements

Test Steps

  1. First step
  2. Second step
  3. Expected result after step 2
  4. ...

Additional Testing Notes

  • Note any edge cases to verify
  • Mention specific areas that need careful testing
  • List known limitations or potential issues

Quality Checklist

  • Changes have been tested locally
  • Documentation has been updated (if required)
  • Performance impact has been considered
  • For SDK features: Test scene is included

Code Review Reference

Please review our Branch & PR Standards before submitting. It explains the automated review flow, QA/DEV approval requirements, and what each label does — especially useful for first-time contributors.

@davidejensen
davidejensen requested review from a team as code owners September 4, 2026 13:52
@github-actions
github-actions Bot requested a review from DafGreco September 4, 2026 13:52
@davidejensen
davidejensen changed the base branch from feat/in-game-shop-panel-stub to dev September 4, 2026 13:52
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

🚦 CI Status

Build

Build failed! Check the logs to see what went wrong.
If the error repeats please consider the clean-build tag.

Platform Links & timing
Windows GitHub job · Unity Cloud #3 · Unity log · ⏱ 29m 52s build + 19m 21s queue
Mac GitHub job · Unity Cloud #3 · ⏱ 27m 54s build + 5m 3s queue

Lint

Warnings not reduced: 12025 => 12061 — remove at least 37 warnings to merge.

Warnings/errors in files changed by this PR (63)
Assets/DCL/ExplorePanel/ExplorePanelController.cs:79  CSharpWarnings::CS8618  Non-nullable fields 'tabsBySections', 'exploreSections', 'sectionSelectorController', 'setupExploreSectionsCts', 'checkForLiveEventsCts' must contain non-null values when exiting constructor. Consider adding the 'required' modifiers or declaring the fields as nullable.
Assets/DCL/PluginSystem/Global/ExplorePanelPlugin.cs:824  CSharpWarnings::CS8618  Non-nullable property 'EventInfoPrefab' is uninitialized. Consider adding the 'required' modifier or declaring the property as nullable.
Assets/DCL/PluginSystem/Global/ExplorePanelPlugin.cs:822  CSharpWarnings::CS8618  Non-nullable property 'PlaceCategoriesSO' is uninitialized. Consider adding the 'required' modifier or declaring the property as nullable.
Assets/DCL/PluginSystem/Global/ExplorePanelPlugin.cs:823  CSharpWarnings::CS8618  Non-nullable property 'PlaceDetailPanelPrefab' is uninitialized. Consider adding the 'required' modifier or declaring the property as nullable.
Assets/DCL/PluginSystem/Global/ExplorePanelPlugin.cs:825  CSharpWarnings::CS8618  Non-nullable property 'QualityPresets' is uninitialized. Consider adding the 'required' modifier or declaring the property as nullable.
Assets/DCL/PluginSystem/Global/ExplorePanelPlugin.cs:810  CSharpWarnings::CS8618  Non-nullable property 'pointAtMarkerVisibilitySettings' is uninitialized. Consider adding the 'required' modifier or declaring the property as nullable.
Assets/DCL/ExplorePanel/ExplorePanelController.cs:231  ConditionalAccessQualifierIsNonNullableAccordingToAPIContract  Conditional access qualifier expression is never null according to nullable reference types' annotations
Assets/DCL/ExplorePanel/ExplorePanelController.cs:231  ConditionalAccessQualifierIsNonNullableAccordingToAPIContract  Conditional access qualifier expression is never null according to nullable reference types' annotations
Assets/DCL/ExplorePanel/ExplorePanelController.cs:231  ConditionalAccessQualifierIsNonNullableAccordingToAPIContract  Conditional access qualifier expression is never null according to nullable reference types' annotations
Assets/DCL/PluginSystem/Global/CreditPurchasePlugin.cs:183  InconsistentNaming  Name 'CreditPurchasePopupPrefab' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'creditPurchasePopupPrefab'.
Assets/DCL/PluginSystem/Global/CreditPurchasePlugin.cs:184  InconsistentNaming  Name 'CreditsTopUpPopupPrefab' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'creditsTopUpPopupPrefab'.
Assets/DCL/PluginSystem/Global/ExplorePanelPlugin.cs:824  InconsistentNaming  Name 'EventInfoPrefab' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'eventInfoPrefab'.
Assets/DCL/PluginSystem/Global/ExplorePanelPlugin.cs:823  InconsistentNaming  Name 'PlaceDetailPanelPrefab' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'placeDetailPanelPrefab'.
Assets/DCL/PluginSystem/Global/ExplorePanelPlugin.cs:825  InconsistentNaming  Name 'QualityPresets' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'qualityPresets'.
Assets/DCL/PluginSystem/Global/CreditPurchasePlugin.cs:185  InconsistentNaming  Name 'RarityInfoPanelBackgroundsMapping' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'rarityInfoPanelBackgroundsMapping'.
Assets/DCL/PluginSystem/Global/CreditPurchasePlugin.cs:188  InconsistentNaming  Name 'ShopCartPopupPrefab' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'shopCartPopupPrefab'.
Assets/DCL/PluginSystem/Global/ExplorePanelPlugin.cs:141  InconsistentNaming  Name 'assetBundleURL' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'assetBundleUrl'.
Assets/DCL/PluginSystem/Global/ExplorePanelPlugin.cs:810  InconsistentNaming  Name 'pointAtMarkerVisibilitySettings' does not match rule 'members_should_be_pascal_case'. Suggested name is 'PointAtMarkerVisibilitySettings'.
Assets/DCL/PerformanceAndDiagnostics/Analytics/EventBased/ExplorePanelAnalytics.cs:17  NotAccessedField.Local  Field 'navmapController' is assigned but its value is never used
Assets/DCL/ExplorePanel/ExplorePanelController.cs:378  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/ExplorePanel/ExplorePanelController.cs:391  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/PerformanceAndDiagnostics/Analytics/EventBased/ExplorePanelAnalytics.cs:1  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/PerformanceAndDiagnostics/Analytics/EventBased/ExplorePanelAnalytics.cs:5  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/PluginSystem/Global/ExplorePanelPlugin.cs:10  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/PluginSystem/Global/ExplorePanelPlugin.cs:92  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/Shop/Collectibles/ShopRarityChipView.cs:13  UnusedAutoPropertyAccessor.Local  Auto-property accessor 'Check.set' is never used
Assets/DCL/Shop/Collectibles/ShopCategoryRowView.cs:12  UnusedAutoPropertyAccessor.Local  Auto-property accessor 'Chevron.set' is never used
Assets/DCL/Shop/Collectibles/ShopFilterSectionView.cs:10  UnusedAutoPropertyAccessor.Local  Auto-property accessor 'Chevron.set' is never used
Assets/DCL/Shop/Overview/ShopItemCardView.cs:79  UnusedAutoPropertyAccessor.Local  Auto-property accessor 'ClickAudio.set' is never used
Assets/DCL/Shop/Overview/ShopItemCardView.cs:37  UnusedAutoPropertyAccessor.Local  Auto-property accessor 'DefaultThumbnail.set' is never used
Assets/DCL/Shop/Overview/ShopOutfitCardView.cs:27  UnusedAutoPropertyAccessor.Local  Auto-property accessor 'DefaultThumbnail.set' is never used
Assets/DCL/Shop/Overview/ShopItemCardView.cs:65  UnusedAutoPropertyAccessor.Local  Auto-property accessor 'FemaleIcon.set' is never used
Assets/DCL/Shop/Overview/ShopItemCardView.cs:78  UnusedAutoPropertyAccessor.Local  Auto-property accessor 'HoverAudio.set' is never used
Assets/DCL/Shop/Overview/ShopItemCardView.cs:64  UnusedAutoPropertyAccessor.Local  Auto-property accessor 'MaleIcon.set' is never used
Assets/DCL/Shop/Overview/ShopCarouselView.cs:33  UnusedAutoPropertyAccessor.Local  Auto-property accessor 'Skeleton.set' is never used
Assets/DCL/Shop/Collectibles/ShopFiltersView.cs:40  UnusedAutoPropertyAccessor.Local  Auto-property accessor 'SmartHint.set' is never used
Assets/DCL/Shop/Collectibles/ShopFilterSectionView.cs:12  UnusedAutoPropertyAccessor.Local  Auto-property accessor 'Summary.set' is never used
Assets/DCL/Shop/Overview/ShopItemCardView.cs:66  UnusedAutoPropertyAccessor.Local  Auto-property accessor 'UnisexIcon.set' is never used
Assets/DCL/Shop/Overview/ShopCarouselView.cs:20  UnusedAutoPropertyAccessor.Local  Auto-property accessor 'ViewAllButton.set' is never used
Assets/DCL/MarketplaceCredits/Purchase/Cart/UI/ShopCartLineView.cs:30  UnusedMember.Local  Method 'Awake' is never used
Assets/DCL/Shop/Collectibles/ShopCategoryRowView.cs:20  UnusedMember.Local  Method 'Awake' is never used
Assets/DCL/Shop/Collectibles/ShopCollectiblesView.cs:76  UnusedMember.Local  Method 'Awake' is never used
Assets/DCL/Shop/Collectibles/ShopFilterChipView.cs:17  UnusedMember.Local  Method 'Awake' is never used
Assets/DCL/Shop/Collectibles/ShopFilterSectionView.cs:17  UnusedMember.Local  Method 'Awake' is never used
Assets/DCL/Shop/Collectibles/ShopFiltersView.cs:59  UnusedMember.Local  Method 'Awake' is never used
Assets/DCL/Shop/Collectibles/ShopRarityChipView.cs:18  UnusedMember.Local  Method 'Awake' is never used
Assets/DCL/Shop/Overview/ShopCarouselView.cs:51  UnusedMember.Local  Method 'Awake' is never used
Assets/DCL/Shop/Overview/ShopItemCardView.cs:96  UnusedMember.Local  Method 'Awake' is never used
Assets/DCL/Shop/Overview/ShopOutfitCardView.cs:49  UnusedMember.Local  Method 'Awake' is never used
Assets/DCL/Shop/Overview/ShopOverviewView.cs:38  UnusedMember.Local  Method 'Awake' is never used

…and 13 more (see the csharp-lint-reports artifact).

Lint run · took 25m 43s

Tests

All Unity tests passed ✅

TESTS SUITE Result Passed Failed Skipped Tests time Job time
EditMode ✅ Passed 26108 0 13 4m 12s 14m 0s
PlayMode ✅ Passed 248 0 37 45s 12m 3s

Tests time sums the test cases; Job time is the job's wall clock including checkout, licensing and asset import.

Slowest tests
  • [editmode] 16.5s DCL.AuthenticationScreenFlow.Tests.ProfileFetchingAuthStateShould.CancelStalledFetchOnTimeout
  • [editmode] 13.1s DCL.Tests.Editor.ValidationTests.CheckForDebugUsage
  • [editmode] 11.6s DCL.Tests.Editor.ValidationTests.CheckUnityObjectsForMissingReferences
  • [editmode] 10.0s DCL.Notifications.Tests.NotificationsRequestControllerShould.ReuseSingleListInstanceAcrossPollIterations
  • [editmode] 5.4s DCL.Tests.Editor.ValidationTests.SettingsAreValid
  • [editmode] 5.0s DCL.Friends.Tests.FriendsConnectivityStatusTrackerShould.RaiseOnlineEventWhenSameStatusIsRebroadcastAfterReset
  • [editmode] 5.0s CrdtEcsBridge.WorldSynchronizer.Tests.CrdtWorldSynchronizerShould.ThrowIfSyncBufferIsAlreadyRented
  • [editmode] 4.3s SceneRunner.Tests.SceneFacadeShould.ContinueUpdateLoopOnBackgroundThread(180,4000)
  • [editmode] 4.1s SceneRunner.Tests.SceneFacadeShould.ContinueUpdateLoopOnBackgroundThread(60,4000)
  • [editmode] 4.1s SceneRunner.Tests.SceneFacadeShould.ContinueUpdateLoopOnBackgroundThread(20,4000)
  • [playmode] 4.4s Global.Tests.PlayMode.CubeWaveSceneShould.EmitECSComponents
  • [playmode] 3.2s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.ContinuousTweensRunIndefinitelyWhenDurationIsZero
  • [playmode] 2.4s DCL.SDKComponents.Tween.Tests.TweenSequenceSystemShould.TextureMoveSequenceUpdatesMaterial
  • [playmode] 2.3s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.RotateContinuousCompletesAfterDuration
  • [playmode] 2.3s DCL.SDKComponents.Tween.Tests.TweenSequenceSystemShould.TweenSequenceWithoutLoopCompletesOnce
  • [playmode] 2.3s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.MoveContinuousMovesAndCompletesAfterDuration
  • [playmode] 2.3s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.TextureMoveContinuousOffsetCompletesAndUpdatesMaterial
  • [playmode] 1.8s DCL.SDKComponents.Tween.Tests.TweenSequenceSystemShould.TweenSequenceWithMoveRotateScaleWithOmittedScale_ResolvesScaleFromCurrentTransform
  • [playmode] 1.6s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.RotateContinuousPositiveAndNegativeYDirectionsAreOpposite
  • [playmode] 1.6s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.RotateContinuousAroundXAxisRotatesAroundXNotZ

Full report: run summary · results + editor logs: editmode · playmode

Performance

🏁 Bare-metal benchmark finished — run #33892386090.

Full report

PR #9982, run #33892386090

Overall: 🟢 GPU average improved on Intel Core i5; GPU 1% worst improved on Intel Core i5

Builds: Windows change, Windows baseline, macOS change, macOS baseline

How to read this table
  • Each build is measured 3 times, interleaved with the other build (change, baseline, change, baseline, ...) in the same session, so both see the same world content and machine state. The values are the median, and (min–max) is the lowest and highest of those runs.
  • Δ is Change minus Baseline (a negative Δ means Change is faster).
  • 🟢 faster / 🔴 slower — a difference that passed every check: the runs are fully separated (every run of one build faster than every run of the other), and the median difference is at least 3% and at least 0.5 ms.
  • ⚪ within noise — the builds' runs overlap, or the difference is tiny; it cannot be told apart from random variation. Treat it as no change.
  • — informational — the 0.1% worst metrics average only the few worst frames of a run, so a single OS hiccup swings them by a lot; they are shown for context and never earn a verdict.
  • ⚠️ no verdict — the two builds' sessions were not comparable (very different sample counts, or too few usable runs), so no conclusion is drawn from them.
  • Exceptions per run — the average number of exceptions in a run's log, not counting teardown ones logged while the app quits. Flagged only on a difference of at least 2 per run and 1.5× the other build; exception kinds the baseline never threw are called out under the table. The Exception breakdown groups all of them by the explorer's report category and exception type (as totals across the runs).
  • A run that logged unusually many exceptions (at least 10 and 5× the median of its build's runs — e.g. a service was down during it) is excluded from all numbers and called out under the table.
  • The Overall line at the top only reacts to a metric that moved on two or more machines, or by 10% or more on one — a single modest 🟢/🔴 cell can still be a statistical fluke.

Apple M1

Metric Baseline Change Δ Result
Samples 3512 (×3) 3576 (×3)
CPU average 25.5 ms (24.8–25.8) 25.0 ms (22.1–25.1) -0.5 ms ⚪ within noise
CPU 1% worst 233.3 ms (233.2–234.5) 230.7 ms (35.2–231.8) -2.6 ms ⚪ within noise
CPU 0.1% worst 241.2 ms (237.1–243.2) 235.6 ms (68.1–236.3) -5.6 ms — informational
GPU average 42.1 ms (41.7–44.3) 41.5 ms (40.7–41.7) -0.6 ms ⚪ within noise
GPU 1% worst 54.1 ms (54.0–56.0) 53.1 ms (49.9–53.6) -1.1 ms ⚪ within noise
GPU 0.1% worst 56.7 ms (55.3–57.6) 55.4 ms (53.1–56.5) -1.3 ms — informational
Exceptions per run 0 0 0 ⚪ no significant change

Intel Core i5

Metric Baseline Change Δ Result
Samples 4133 (×3) 4898 (×3)
CPU average 21.6 ms (20.6–22.2) 18.3 ms (16.6–21.4) -3.2 ms ⚪ within noise
CPU 1% worst 406.1 ms (374.7–449.6) 326.6 ms (41.8–418.8) -79.5 ms ⚪ within noise
CPU 0.1% worst 433.3 ms (404.4–472.1) 399.1 ms (135.5–447.2) -34.2 ms — informational
GPU average 13.9 ms (13.4–14.7) 11.0 ms (10.7–13.2) -2.8 ms 🟢 21% faster
GPU 1% worst 280.4 ms (254.4–354.1) 143.9 ms (22.7–248.1) -136.6 ms 🟢 49% faster
GPU 0.1% worst 427.5 ms (401.0–472.5) 398.4 ms (27.1–440.8) -29.1 ms — informational
Exceptions per run 0 0 0 ⚪ no significant change

Automation

On demand — comment /visual-tests on this PR to run the visual regression suite against its build.

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — feat: in game shop

STEP 2 — Root-cause check

This PR adds an in-game shop as a new ISection in the explore panel, with a cart, multi-group checkout, and analytics. The change is a new feature, not a fix.

STEP 3 — Design & integration

PASS. The architecture follows established patterns:

  1. ShopController implements ISection — the same pattern used by Navmap, Backpack, Events, etc. It plugs into ExplorePanelController's section selector. Searched: ExplorePanelController.SetupExploreSectionsAsync, SectionSelectorController. The explore panel IS the lifecycle owner of its sections; ShopController belongs there.

  2. ShopCart is a new domain concept (in-memory shopping cart). No existing owner manages the cart lifecycle — it is created in DynamicWorldContainer.CreateAsync, shared across ExplorePanelPlugin (for the shop UI) and CreditPurchasePlugin (for the cart modal), and owned/disposed by CreditPurchasePlugin. Ownership is clear; no double-dispose.

  3. CreditsCartCheckoutService — new service handling the multi-group reserve→sign→settle flow. Created alongside ShopCart in DynamicWorldContainer, disposed by CreditPurchasePlugin.

  4. UseCreditsExecutor — cleanly extracted from CreditsPurchaseService. The signing, settlement polling, and credit-release logic that was previously inlined in CreditsPurchaseService.PurchaseInternalAsync is now a reusable struct consumed by both the single-item flow and the cart checkout. Good consolidation — no code duplication.

  5. ShopCatalogService — TTL cache with in-flight coalescing, owned by ShopController. No lifecycle duplication.

Subscription/disposal trace (all confirmed):

  • ShopController: 10 event subs in ctor, all unsubscribed in Dispose ✓
  • ShopOverviewController: 5 event subs, all unsubscribed in Dispose ✓
  • ShopCollectiblesController: 11 event subs, all unsubscribed in Dispose ✓
  • ShopCart: 2 identity subs, unsubscribed in Dispose ✓
  • ShopAnalytics: 7 subs, unsubscribed in Dispose ✓
  • ShopCartAnalytics: 6 subs, unsubscribed in Dispose (via MVC decorator) ✓
  • ShopCartModalController: subscribes OnViewShow, unsubscribes OnViewClose ✓
  • All CancellationTokenSources have SafeCancelAndDispose paths ✓

STEP 4 — Member audit

Member Consumers Status
ShopItemCardModel.IsPrimary Presenter, CartLine, Model factories Multi-use ✓
ShopItemCardModel.IsNotForSale Presenter, Overview, Collectibles Multi-use ✓
ShopCart.Contains (3 overloads) Presenter, Cart, Overview Multi-use ✓
ShopCatalogService.Invalidate ShopController Single consumer, clear intent ✓
UseCreditsExecutor.ReleaseIntentAsync CreditsPurchaseService, CreditsCartCheckoutService Multi-use ✓
ShopItemCardPresenter.PurchasesEnabled ShopController.Activate Single setter, read-only by cards — acceptable

No single-use-merge, absent-≠-false, or redundant-guard issues found.

STEP 5 — Line-level findings

Overall code quality is high. Tests cover the cart (ShopCartShould), checkout (CreditsCartCheckoutServiceShould), trade encoder (CreditsTradeEncoderShould), and API client (MarketplaceShopAPIClientShould). Naming, async patterns, error handling, and resource lifecycle follow CLAUDE.md conventions. Three P2 observations below.

Security review

No security issues found. No secrets committed, no injection surfaces, RandomSalt uses RandomNumberGenerator.Create (cryptographically secure), auth flows use the existing web3 identity system, all API data is server-sourced with proper deserialization.

STEP 6 — Complexity

COMPLEX — adds plugin/container wiring, new assemblies, async checkout flow, dependency injection changes across 100+ files.

STEP 7 — QA assessment

QA_REQUIRED: YES — user-facing shop UI, cart, animations, checkout flow, and runtime code.

STEP 8 — Non-blocking warnings

None. Main scene not modified.

STEP 9 — Verdict

REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Adds a full in-game shop (catalog, cart, multi-group checkout, analytics) with plugin/container wiring across multiple assemblies.
QA_REQUIRED: YES


Reviewed by Jarvis 🤖 · Requested by decentraland-bot via GitHub

UniTaskCompletionSource<T>? inFlight = slot.InFlight;

if (inFlight == null)
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Slot.Clear() does not discard the in-flight completion source. A fetch that started before Invalidate() (e.g. before a purchase settled) completes afterward and silently writes its result — so the "invalidated" data is replaced by pre-invalidation server state. The window is short in practice (settlement takes seconds, the fetch is long done), but the intent of invalidation is weakened.

Add a comment documenting this trade-off so future maintainers don't assume Invalidate guarantees a fresh fetch:

Suggested change
{
public void Clear()
{
Value = null;
FetchedAtUtc = DateTime.MinValue;
// InFlight is intentionally kept: a fetch already in progress will deliver near-current
// data, and nulling the completion source would orphan callers awaiting it. The next
// GetOrFetchAsync after the in-flight settles will see the stale FetchedAtUtc and re-fetch.
}

private readonly MarketplaceShopAPIClient api;
private readonly TimeSpan ttl;
private readonly System.Random random = new ();
private readonly Slot<IReadOnlyList<ShopItemCardModel>> trending = new ();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] ShopCatalogService holds mutable state (three Slot<T> instances, a System.Random) but its XML summary omits the threading contract. Per CLAUDE.md Step 5B: "A stateful class with caches or mutable fields whose XML summary omits its threading contract."

Suggested change
private readonly Slot<IReadOnlyList<ShopItemCardModel>> trending = new ();
/// <summary>
/// The overview datasets (trending, new creations, outfits) behind a TTL cache with in-flight coalescing, so
/// reopening the shop within the TTL is instant and concurrent openers share one request. A purchase
/// invalidates everything: stock and prices moved.
/// <para>Not thread-safe — all calls must happen on the Unity main thread.</para>
/// </summary>
public class ShopCatalogService

/// </summary>
public sealed class ShopCart : IDisposable
{
private readonly IWeb3IdentityCache identityCache;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] ShopCart holds mutable collections and identity-tracking state but lacks a threading-contract note. Per CLAUDE.md Step 5B.

Suggested change
private readonly IWeb3IdentityCache identityCache;
/// <summary>
/// The in-memory shopping cart, shared by the shop UI (adds) and the checkout (removes what settled). It
/// belongs to the signed-in account: a different address signing in starts from an empty cart, signing out
/// keeps it.
/// <para>Not thread-safe — all calls must happen on the Unity main thread.</para>
/// </summary>
public sealed class ShopCart : IDisposable

@decentraland-bot
decentraland-bot self-requested a review September 14, 2026 08:53

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In-Game Shop — Code Review

Summary

This PR introduces a complete in-game shop for the Unity Explorer:

  • Shopping cart (ShopCart) with identity-based ownership, quantity management, and stock-cap enforcement
  • Batch credits checkout (CreditsCartCheckoutService) — group reservation → sequential sign/settle per group, with correct partial-failure handling
  • Marketplace API clients extended for unified listings, catalog items, outfits, and trending content
  • Shop UI — overview page with carousels, collectibles grid with filters/search/pagination, category tree, and cart modal
  • Analytics — shop and cart funnel events (Segment-compatible with the web shop)
  • Plugin wiring in CreditPurchasePlugin and ExplorePanelPlugin

The feature is well-structured with clean MVC separation. 49 unit tests across 7 test files cover the checkout flow, trade encoding, API client URL building, cart operations, catalog caching, card model construction, and query mapping.

Root-Cause Check

N/A — new feature, not a bugfix.

Design & Integration

Strengths:

  • Two-phase checkout (reserve all → sign/settle) correctly handles partial failures — unsigned groups release their credits, broadcast groups conservatively keep them
  • Slot<T> TTL cache with in-flight coalescing in ShopCatalogService prevents duplicate fetches
  • Object pooling in views (chips, category rows, cards) follows project performance conventions
  • Event subscription/unsubscription is symmetric in all Dispose methods
  • ShopCartLine snapshots the listing at add-time, isolating the cart from external mutations
  • Nonce sequencing via nextMinNonce correctly prevents nonce reuse across sequential groups
  • RandomSalt() uses RandomNumberGenerator (cryptographically secure)
  • IsCheckoutInFlight as a plain bool is safe under UniTask's single-threaded cooperative model

Concerns (see inline comments):

  • Several services created inside constructors rather than injected (DI conventions)
  • Shared instances disposed by a single plugin — disposal order is load-bearing

Test Coverage

Good coverage of critical paths (49 test methods). Missing direct tests for UseCreditsExecutor (tested indirectly via checkout service mocks) and UI controllers (understandable for MonoBehaviour-heavy code).

Findings Summary

Severity Count Category
P1 3 Input validation in financial arithmetic
P1 2 DI / lifecycle conventions
P2 5 Defensive hardening

Not in diff but worth noting (existing code exposed by new callers):

  • CreditsTradeEncoder.CeilToCents casts BigInteger to int without overflow protection — extreme server values silently wrap
  • CreditsTradeEncoder.UsdWeiToManaWei divides by rate.Rate without a zero-guard on the public API
  • CreditsTradeEncoder.RoundUpToWholeCredit divides by centsPerCredit without a zero-guard

Non-Blocking Warnings

  • ShopCreatorNameCache accumulates name mappings without eviction — acceptable for current scale
  • ShopCart.RemoveAll is O(n×m) — acceptable for realistic cart sizes
  • ShopCollectiblesFilters.BuildAnalyticsSignature allocates a StringBuilder per call — fine for filter-change frequency
  • The lifetimeCts vs uiCt cancellation split in Phase B is correct but the comment at line 262 ("buyer's cancellation is ignored") is misleading — uiCt IS still checked between groups (line 273); only in-flight signing/settlement ignores it

DEPENDENCY_REVIEW: PASS

REVIEW_RESULT: PASS
COMPLEXITY: L
COMPLEXITY_REASON: ~145 files, ~8600 lines of new C#; new in-game shop spanning cart, checkout, API clients, UI, analytics, and plugin wiring
QA_REQUIRED: YES

Reviewed by Jarvis 🤖 · Requested by decentraland-bot via GitHub

Comment on lines +245 to +246
BigInteger authorizedCap = BigInteger.Parse(response.maxCreditedValue)
+ CreditsTradeEncoder.UncreditedValue(response.maxCreditedValue, response.credit.availableAmount);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1 — Input Validation] BigInteger.Parse(response.maxCreditedValue) is called without validating that the server response field is non-null and numeric. A malformed response throws FormatException, caught as UnknownError rather than AuthorizationFailed. Validate immediately after deserialization; apply the same pattern to credit.amount, credit.availableAmount, and credit.signature.

Suggested change
BigInteger authorizedCap = BigInteger.Parse(response.maxCreditedValue)
+ CreditsTradeEncoder.UncreditedValue(response.maxCreditedValue, response.credit.availableAmount);
if (!BigInteger.TryParse(response.maxCreditedValue, out BigInteger maxCredited))
return AuthorizationFailure(group, CreditsPurchaseError.AuthorizationFailed, "Invalid maxCreditedValue from server");
BigInteger authorizedCap = maxCredited
+ CreditsTradeEncoder.UncreditedValue(response.maxCreditedValue, response.credit.availableAmount);

Comment on lines +109 to +110
authorizedCap = BigInteger.Parse(request.MaxCreditedValue)
+ CreditsTradeEncoder.UncreditedValue(request.MaxCreditedValue, credit.availableAmount);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1 — Input Validation] Same issue: BigInteger.Parse(request.MaxCreditedValue) can throw on malformed data after the credit has already been authorized. The intent gets released by the catch block, but the error surfaces as a generic exception rather than a typed failure.

Suggested change
authorizedCap = BigInteger.Parse(request.MaxCreditedValue)
+ CreditsTradeEncoder.UncreditedValue(request.MaxCreditedValue, credit.availableAmount);
if (!BigInteger.TryParse(request.MaxCreditedValue, out BigInteger maxCredited))
return new UseCreditsOutcome(CreditsPurchaseError.EncodingFailed, "Invalid MaxCreditedValue");
authorizedCap = maxCredited
+ CreditsTradeEncoder.UncreditedValue(request.MaxCreditedValue, credit.availableAmount);

Comment on lines +216 to +220
public void Clear()
{
Value = null;
FetchedAtUtc = DateTime.MinValue;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2 — Race Condition] Clear() resets Value and FetchedAtUtc but leaves InFlight intact. If Invalidate() is called while a fetch is in progress, the completing fetch writes stale data back into Value/FetchedAtUtc, defeating the invalidation. This can happen when a purchase settles while the overview is loading.

Suggested change
public void Clear()
{
Value = null;
FetchedAtUtc = DateTime.MinValue;
}
public void Clear()
{
Value = null;
FetchedAtUtc = DateTime.MinValue;
InFlight = null;
}

Comment on lines +12 to +13
public static string BuildItemUrl(IDecentralandUrlsSource urlsSource, string contractAddress, string itemId) =>
$"{urlsSource.Url(DecentralandUrl.ShopLink)}/item/{contractAddress}/{itemId}?utm_source=client";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2 — Input Sanitization] contractAddress and itemId are embedded in the URL path without encoding. While these come from API responses (low risk), a compromised server could inject path traversal or query parameters.

Suggested change
public static string BuildItemUrl(IDecentralandUrlsSource urlsSource, string contractAddress, string itemId) =>
$"{urlsSource.Url(DecentralandUrl.ShopLink)}/item/{contractAddress}/{itemId}?utm_source=client";
public static string BuildItemUrl(IDecentralandUrlsSource urlsSource, string contractAddress, string itemId) =>
$"{urlsSource.Url(DecentralandUrl.ShopLink)}/item/{Uri.EscapeDataString(contractAddress)}/{Uri.EscapeDataString(itemId)}?utm_source=client";

Comment on lines +97 to +98
cartCheckoutService.Dispose();
shopCart.Dispose();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1 — Lifecycle] ShopCart and ICreditsCartCheckoutService are shared with ExplorePanelPlugin/ShopController, but only CreditPurchasePlugin.Dispose() disposes them. If this plugin disposes first, the other consumers operate on disposed objects. Per CLAUDE.md §11, plugins should not own shared container-level lifecycles.

Consider moving disposal of these shared instances to DynamicWorldContainer (which created them), and remove the dispose calls here:

Suggested change
cartCheckoutService.Dispose();
shopCart.Dispose();
// ShopCart and cartCheckoutService are shared — disposed by the container, not by this plugin.

Comment on lines +83 to +84
catalog = new ShopCatalogService(api);
var creatorNames = new ShopCreatorNameCache(profileRepositoryWrapper);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1 — DI Convention] ShopCatalogService and ShopCreatorNameCache are created internally rather than injected. Their lifetimes are invisible to the composition root and cannot be substituted in tests. ShopCatalogService holds an in-flight coalescing cache whose lifetime couples to the controller — in-flight fetches may complete after the controller is disposed.

Consider creating these in the plugin/container and injecting them:

Suggested change
catalog = new ShopCatalogService(api);
var creatorNames = new ShopCreatorNameCache(profileRepositoryWrapper);
// TODO: inject ShopCatalogService and ShopCreatorNameCache from the container
catalog = new ShopCatalogService(api);
var creatorNames = new ShopCreatorNameCache(profileRepositoryWrapper);

this.identityCache = identityCache;
this.creditsFeatureAccess = creditsFeatureAccess;
this.isFeatureEnabled = isFeatureEnabled;
executor = new UseCreditsExecutor(creditsAPIClient, metaTxRelayer, settlementPoller);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1 — DI Convention] UseCreditsExecutor is newed internally here and also in CreditsPurchaseService. Both services share the same three collaborators (creditsAPIClient, metaTxRelayer, settlementPoller) but create independent executor instances, hiding the dependency from tests. Create a single UseCreditsExecutor in the container and inject it into both services.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants