Skip to content

feat: added PBExplorerItemPurchaseResult and iap open event - #464

Merged
popuz merged 15 commits into
mainfrom
feat/openexplorerui-iap-extension
Sep 11, 2026
Merged

popuz merged 15 commits into
mainfrom
feat/openexplorerui-iap-extension

Conversation

@davidejensen

@davidejensen davidejensen commented Aug 14, 2026

Copy link
Copy Markdown
Member

Extend OpenExplorerUi to support in-scene item purchases

Adds support for opening the item-purchase confirmation modal from scene code, reusing the existing OpenExplorerUi restricted action instead of introducing a separate RPC (as was previously attempted in #462).

Changes

  • explorer_ui.proto: adds EU_ITEM_PURCHASE = 7 to the ExplorerUi enum, representing the purchase confirmation modal.
  • restricted_actions.proto: activates the extension point on OpenExplorerUiRequest with a oneof params containing an ItemPurchaseParams message (string urn). This lets the scene pass the item URN when requesting EU_ITEM_PURCHASE. The response message is unchanged — it returns OPENED / WAS_ALREADY_OPEN / REJECTED_* immediately.
  • restricted_actions.proto: adds optional uint32 request_id = 2 to OpenExplorerUiRequest. The explorer echoes it on every event the request produces, so a scene can match events to the call that caused them. Unset means no correlation, and 0 is reserved for the same meaning on the result side, so scene-minted ids start at 1.
  • explorer_item_purchase_result.proto (new, component ID 1222 — 1221 was claimed by avatar_nametag while this PR was open): a grow-only CRDT component (PBExplorerItemPurchaseResult) appended to the scene root entity. Reports the asynchronous outcome of the purchase flow via a oneof status { Purchased, Dismissed, Failed } alongside the original urn, the scene-tick timestamp, and the echoed request_id. This follows the same pattern as PBExplorerUiEventsResult (1220) but keeps purchase-specific outcomes separate, since Purchased/Dismissed/Failed are not meaningful for other ExplorerUi panels.
  • explorer_ui_events_result.proto: 1220 gains the same uint32 request_id = 3 echo, so panel lifecycle events are attributable to their call too.
  • WAS_ALREADY_OPEN semantics relaxed: it used to assert that a fullscreen panel is a global mutex; it now means the slot the requested panel occupies is in use, which is what lets a modal like the purchase confirmation coexist with a fullscreen panel. Comment-only sweep of the "fullscreen" wording follows from this.

Design decisions

  • The purchase outcome is delivered asynchronously via the new CRDT component rather than synchronously in the RPC response. This avoids blocking the restricted action call for the entire purchase modal lifecycle and is consistent with how OpenExplorerUi works for other panels.
  • request_id is scene-minted, not explorer-minted: events can reach the scene before the RPC response does, so an explorer-assigned id would force scenes to rescan accumulated events. On the results it stays a plain uint32 with 0 reserved — the explorer is the only writer, and optional there would create two ways to say "no correlation".
  • Failed is intentionally coarse (no error sub-types) to prevent wallet-balance probing via differential error analysis.
  • Status variants use empty messages inside a oneof rather than an enum, allowing future per-status metadata (e.g. transaction hash on Purchased) without breaking the wire format.
  • The client resolves pricing independently from the URN; the scene never handles prices or wallet details.
  • The verdict for EU_ITEM_PURCHASE with params unset (or params on a mismatched ui) is deliberately left undefined for this iteration; a REJECTED_INVALID_PARAMS value can be added additively if experience shows it is needed.

@davidejensen davidejensen self-assigned this Aug 14, 2026
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown

Test this pull request

  • The @dcl/protocol package can be tested in scenes by running
    npm install "https://sdk-team-cdn.decentraland.org/@dcl/protocol/branch//dcl-protocol-1.0.0-34642702752.commit-5516c81.tgz"

Comment thread proto/decentraland/sdk/components/explorer_purchase_event_result.proto Outdated
popuz
popuz previously approved these changes Aug 17, 2026
@davidejensen davidejensen changed the title feat: added PBExplorerPurchaseEventResult and iap open event feat: added PBExplorerItemPurchaseResult and iap open event Aug 17, 2026
pravusjif
pravusjif previously approved these changes Aug 17, 2026

@pravusjif pravusjif left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nice!

@popuz popuz 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.

Overall this is the extension model working as designed, nice: EU_ITEM_PURCHASE is the cheap "new panel = new enum value" path, oneof params materializes the extension point exactly as its placeholder comment described, and the separate outcome component follows the recipe the 1220 extension-point comment prescribes ("an event that belongs to one panel alone … gets its own result component"). Two design-level questions I'd like settled before this merges, since scene code will be written against the answers:

1. What does WAS_ALREADY_OPEN mean once modals join the namespace? The comments were relaxed from "fullscreen explorer panels" to "explorer UI panels", which implies the purchase modal is not fullscreen. The one-open-panel invariant is what gives WAS_ALREADY_OPEN its meaning, and consumers rely on it (the SDK-side wait-for-close helper in decentraland/js-sdk-toolchain#1543 assumes close events are delivered only to the scene whose call opened the panel). Please define explicitly — and record in the proto comments: does an open fullscreen panel (say the map) make EU_ITEM_PURCHASE return WAS_ALREADY_OPEN? Does an open purchase modal block opening the map? One shared slot, or fullscreen panels and modals as two independent slots?

2. Purchase results correlate only by urn. Two flows for the same urn close together (double-click, scene retry) produce indistinguishable results. Suggested additive fix while the wire is young: a scene-supplied string correlation_id in PurchaseParams, echoed back in PBExplorerItemPurchaseResult. One optional field on each side, fully backward compatible, and it turns the SDK-side "await the purchase outcome" from a FIFO heuristic into an exact match. We hit exactly this ambiguity class with 1220, where the absence of a session id forces heuristics; here it is still cheap to avoid.

Inline nits on the specific lines.

Comment thread proto/decentraland/sdk/components/common/explorer_ui.proto Outdated
Comment thread proto/decentraland/kernel/apis/restricted_actions.proto
Comment thread proto/decentraland/sdk/components/explorer_item_purchase_event_result.proto Outdated
Comment thread public/sdk-components.proto Outdated
@davidejensen
davidejensen dismissed stale reviews from pravusjif and popuz via 7a07f26 August 17, 2026 12:28
@davidejensen
davidejensen requested a review from popuz August 17, 2026 12:29
popuz
popuz previously approved these changes Aug 17, 2026
  Adds an optional request_id to OpenExplorerUiRequest, echoed back in
  PBExplorerUiEventsResult (1220) and PBExplorerItemPurchaseResult (1221).

  Today a scene cannot tell which events belong to which openExplorerUi call.
  The explorer knows the pairing -- opened and closed are emitted from a single
  await scope of one call -- but it is lost at the wire boundary, so SDK helpers
  have to guess it from timestamp windows and per-panel FIFO matching.

  The scene sets the id before sending the request, so events that arrive before
  the RPC response resolves are still correlatable. 0 is the proto3 default and
  means uncorrelated, which keeps the change additive for clients that do not
  implement it.

  This is not the transaction_id deferred earlier in review: that one is about
  purchase semantics, this one is about binding a call to its own event stream.
popuz and others added 6 commits August 19, 2026 14:51
A proto3 scalar without `optional` generates a required TypeScript field,
so `openExplorerUi({ ui })` would stop compiling for every existing caller
-- including scenes already on @dcl/sdk@next. Explicit presence keeps the
change additive and lets the SDK omit the field instead of sending a
sentinel.

The result components keep the plain uint32: scenes only read those, and 0
reads better there than an undefined check at every access.

Co-Authored-By: Claude <noreply@anthropic.com>
EU_ITEM_PURCHASE is a popup, not a fullscreen panel, so it can be shown on
top of an already open one. WAS_ALREADY_OPEN now means the requested panel
is open rather than any panel, which is what makes concurrent sessions
expressible -- and what makes request_id load bearing instead of a nicety.

Also points the purchase outcome at 1221; 1220 is the component the line
above it describes.

Co-Authored-By: Claude <noreply@anthropic.com>
The two look redundant when read from the caller's side, which invites
removing one of them. They are not: the response is point to point, the
component is a scene wide stream.

Co-Authored-By: Claude <noreply@anthropic.com>

@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: PR #464 — feat: added PBExplorerItemPurchaseResult and iap open event

Overall: The design is sound — reusing OpenExplorerUi with the oneof-params extension point is clean, the async CRDT pattern for purchase outcomes is the right call, and request_id for correlation addresses the feedback from the earlier review round. However, there is a critical component-ID collision that blocks merge, plus a couple of issues to resolve.


P0 — Blocker

Component ID 1221 collision with avatar_nametag.proto

explorer_item_purchase_result.proto declares ecs_component_id = 1221, but that ID is already claimed by avatar_nametag.proto (PBAvatarNametag). Two components sharing the same CRDT ID causes runtime data corruption — purchase results written as component 1221 would be deserialized as nametag data by existing clients, and vice versa.

Fix: Assign the next free ID. The current highest is 1221 (avatar_nametag), so 1222 is the next available.


P1 — Should fix before merge

1. request_id optional/non-optional mismatch

OpenExplorerUiRequest.request_id is correctly declared optional uint32 (distinguishes unset from 0). But both result messages (PBExplorerItemPurchaseResult.request_id and PBExplorerUiEventsResult.request_id) use bare uint32 request_id = 3 with the comment "0 if unset." A scene that explicitly sends request_id = 0 gets a result indistinguishable from "no correlation."

Fix: Declare optional uint32 request_id = 3 on both result messages to match the request side.

2. WAS_ALREADY_OPEN semantic shift needs explicit documentation

The comment changed from "a fullscreen panel is already open" (mutual-exclusion invariant) to "the requested panel is already open; other panels may be open too" (self-check only). Consumers in unity-explorer and bevy-explorer reference this enum value and may enforce one-open-panel logic based on the old semantics.

The semantic shift is necessary for modals to coexist with fullscreen panels, but it should be documented explicitly in the proto comment — state whether:

  • A fullscreen panel blocks opening a modal (and vice versa)
  • Multiple modals can be open simultaneously
  • Which panel types share a slot and which are independent

This is the same concern raised in the earlier review round; recording the answer in the proto comments is the contract.


P2 — Minor / Nice-to-have

3. Stale RPC doc (line 140 of restricted_actions.proto)

The OpenExplorerUi RPC description still says "opens a specific fullscreen explorer panel" — now inaccurate since EU_ITEM_PURCHASE is a modal. Update to:

// OpenExplorerUi opens an explorer panel (fullscreen or modal) and returns the open verdict.

4. Missing REJECTED_INVALID_PARAMS verdict

What should the explorer return when EU_ITEM_PURCHASE is sent with the params oneof unset, or when purchase params are sent with a different ui value? Without an explicit answer each client improvises. An additive REJECTED_INVALID_PARAMS enum value would be cheap and clarify the contract (also raised in the earlier review round).

5. Trailing whitespace

Line 93 of restricted_actions.proto (the blank line between PurchaseParams closing brace and the oneof params block) has trailing whitespace.


API Contract / Backward Compatibility

All wire-format changes are additive and backward-compatible:

  • EU_ITEM_PURCHASE = 7 — new enum value; old clients preserve it as integer 7
  • request_id at previously unused field numbers 2 and 3 — old consumers ignore unknown fields
  • oneof params at field 10 — never on the wire before
  • Component 1221 — new component (once the ID collision is fixed)

No consumers of EU_ITEM_PURCHASE or PBExplorerItemPurchaseResult exist yet across unity-explorer, js-sdk-toolchain, or bevy-explorer — this is all new surface. The WAS_ALREADY_OPEN semantic change (P1 above) is the only contract risk for existing consumers.

Security

  • Restricted-action gating (current-scene + user-gesture) is appropriate for purchase operations, consistent with OpenExternalUrl and OpenNftDialog
  • No price/wallet data flows through the protocol — client resolves pricing independently ✅
  • Coarse Failed status (no error sub-types) is a reasonable tradeoff against wallet-balance probing ✅
  • request_id is scene-controlled but only echoed back to the same scene — no cross-scene leakage ✅
  • No secrets, credentials, dependencies, CI/CD, or automation changes

DEPENDENCY_REVIEW: NEEDS_ATTENTION

(Component ID collision must be resolved; no dependency or supply-chain concerns.)

ADR-6 Compliance

  • ✅ PR title follows feat: <summary> format
  • ✅ Branch name follows feat/<summary> pattern

CI Status

check_and_build and Validate compatibility with main branch are still running (the compatibility check may catch the ID collision).


Reviewed by Jarvis 🤖 · Requested by Vitaly Popuzin (<@U03V3D7E0NL>) via Slack

Comment thread proto/decentraland/sdk/components/explorer_item_purchase_result.proto Outdated
Comment thread proto/decentraland/sdk/components/explorer_item_purchase_result.proto Outdated
Comment thread proto/decentraland/sdk/components/explorer_ui_events_result.proto Outdated
Comment thread proto/decentraland/kernel/apis/restricted_actions.proto Outdated
popuz and others added 3 commits September 11, 2026 21:37
avatar_nametag claimed 1221 on main while this branch was open, and merging
main in made both files declare it. PBExplorerItemPurchaseResult moves to 1222.

request_id stays a plain uint32 on the result side. The explorer writes that
field, and optional would hand it two ways to say nothing while forcing every
reader to handle undefined. Zero is reserved for uncorrelated instead, so ids
start at 1 and an explicit zero cannot be mistaken for an absent one.

Also states the slot model behind WAS_ALREADY_OPEN without pinning down which
panels share a slot, drops fullscreen from the OpenExplorerUi doc now that a
panel can be a modal, and removes the trailing whitespace found in review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three-line block carried a number that the id move to 1222 had just made
wrong. Naming PBExplorerItemPurchaseResult instead survives the next move. The
sentence about UiOpened/UiClosed went with it: 1220 already declares its oneof
meaningful for every value of ui, so repeating it here only restated the
default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@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.

Re-review: PR #464 — feat: added PBExplorerItemPurchaseResult and iap open event

Previous blocker resolved. The P0 component-ID collision (1221→1222) is fixed and CI confirms compatibility. All prior findings have been addressed or adequately documented. No P0 or P1 issues remain.


Previous findings — status

# Previous severity Finding Status
1 P0 Component ID 1221 collision with avatar_nametag.proto Fixed — now 1222, no conflicts
2 P1 request_id optional mismatch (request vs result) Addressed — convention documented: "0 when the request carried none", IDs start at 1. See P2 note below.
3 P1 WAS_ALREADY_OPEN semantic shift Addressed — comment now defines slot-based exclusion: "the slot the requested panel occupies is already in use; panels sharing a slot exclude each other"
4 P2 Stale RPC doc ("fullscreen explorer panel") Fixed — now "fullscreen or modal"
5 P2 Missing REJECTED_INVALID_PARAMS ⚠️ Open — see below
6 P2 Trailing whitespace Fixed

Remaining P2 — Minor (non-blocking)

1. request_id consistency (downgraded from P1)
Both result messages (PBExplorerItemPurchaseResult.request_id and PBExplorerUiEventsResult.request_id) use bare uint32 while the request uses optional uint32. The convention is now clearly documented ("0 when the request carried none" / "ids start at 1"), so this works correctly in practice. Making the result fields optional too would be cleaner — it allows consumers to use has-presence checks instead of sentinel comparisons — but the documented convention is adequate.

2. Slot-model formalization
The WAS_ALREADY_OPEN comment introduces the "slot" concept but doesn't specify which panels share which slots (e.g. "fullscreen panels share one exclusive slot; modals occupy a separate, independent slot"). Implementors in unity-explorer and bevy-explorer will need to know this. Consider adding a brief slot-assignment table in the enum comment, or documenting it in the implementation PRs.

3. REJECTED_INVALID_PARAMS verdict
Still no defined behavior for EU_ITEM_PURCHASE with params unset, or purchase params with a different ui value. Each client will improvise. An additive enum value would be cheap and would clarify the contract.

4. URN length (protocol-level)
PurchaseParams.urn has no documented maximum length. Client-side validation should enforce a reasonable bound, but noting it for completeness.


Wire Compatibility

All changes are additive and backward-compatible ✅

  • EU_ITEM_PURCHASE = 7 — new enum value; old clients preserve as integer 7
  • request_id at previously unused field numbers (2, 3) — old consumers ignore
  • oneof params at field 10 — new extension point
  • Component 1222 — new, no collision

Consumer Impact

No consumers of EU_ITEM_PURCHASE or PBExplorerItemPurchaseResult exist yet across unity-explorer, js-sdk-toolchain, or bevy-explorer — this is all new surface. The WAS_ALREADY_OPEN comment change is documentation-only; wire format is unchanged.

Security

  • Restricted-action gating (current-scene + user-gesture) appropriate for purchase operations ✅
  • No price/wallet data flows through the protocol — client resolves pricing independently ✅
  • Coarse Failed status prevents wallet-balance probing via differential error analysis ✅
  • request_id is scene-controlled and echoed only to the same scene — no cross-scene leakage ✅
  • No secrets, credentials, dependencies, or automation changes in the diff ✅

DEPENDENCY_REVIEW: PASS

Pattern Compliance

  • Field layout mirrors PBExplorerUiEventsResult (1220): identifier at 1, timestamp at 2, request_id at 3, oneof at 10+ ✅
  • oneof status (vs 1220's oneof event) is semantically justified — purchase outcomes ≠ lifecycle events ✅
  • Naming follows PB prefix + Result suffix convention ✅
  • sdk-components.proto import in correct alphabetical position ✅
  • ADR-6: PR title feat: <summary>

CI

All checks passing ✅

  • check_and_build
  • Validate compatibility with main branch
  • Deployment Notification

Reviewed by Jarvis 🤖 · Requested by Vitaly Popuzin (<@U03V3D7E0NL>) via Slack

PurchaseParams and its oneof member said purchase where the enum value and the
result component say item purchase; ItemPurchaseParams/item_purchase makes the
trio deterministic. Names are not on the wire and nothing consumes this yet, so
the rename costs nothing.

The EU_ITEM_PURCHASE comment now points at OpenExplorerUi instead of naming
symbols from files that import this one - those can change without this file
noticing, and the comment would rot the way the 1221 reference just did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@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.

Re-review (round 3): PR #464 — latest commit ae97bc5

Approved — the new commit is a naming-only refactor with no wire-format impact.

What changed since last review (b7d9f82ae97bc5)

PurchaseParamsItemPurchaseParams, purchaseitem_purchase in the oneof. Aligns the param message name with the EU_ITEM_PURCHASE enum and PBExplorerItemPurchaseResult component — the naming trio is now consistent. Proto field names are not on the wire, so this is purely cosmetic.

The EU_ITEM_PURCHASE comment now points at OpenExplorerUi instead of naming symbols from importing files — more stable against future renames. Good change.

Previous findings — all unchanged

All P0/P1 issues remain resolved from round 2. P2 notes (non-blocking) still apply:

  • request_id bare vs optional on result side
  • Slot-model assignments not enumerated
  • No REJECTED_INVALID_PARAMS verdict
  • No documented URN max length

CI

check_and_build and Validate compatibility with main branch are running on the new commit.

DEPENDENCY_REVIEW: PASS


Reviewed by Jarvis 🤖 · Requested by Vitaly Popuzin (<@U03V3D7E0NL>) via Slack

@popuz
popuz merged commit 6402953 into main Sep 11, 2026
3 checks passed
@popuz
popuz deleted the feat/openexplorerui-iap-extension branch September 11, 2026 20:13
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.

4 participants