feat: added PBExplorerItemPurchaseResult and iap open event - #464
Conversation
Test this pull request
|
popuz
left a comment
There was a problem hiding this comment.
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.
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.
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
left a comment
There was a problem hiding this comment.
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 7request_idat previously unused field numbers 2 and 3 — old consumers ignore unknown fieldsoneof paramsat 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
OpenExternalUrlandOpenNftDialog✅ - No price/wallet data flows through the protocol — client resolves pricing independently ✅
- Coarse
Failedstatus (no error sub-types) is a reasonable tradeoff against wallet-balance probing ✅ request_idis 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
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>
…n' into feat/openexplorerui-iap-extension
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
left a comment
There was a problem hiding this comment.
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 |
|
| 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 7request_idat previously unused field numbers (2, 3) — old consumers ignoreoneof paramsat 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
Failedstatus prevents wallet-balance probing via differential error analysis ✅ request_idis 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'soneof event) is semantically justified — purchase outcomes ≠ lifecycle events ✅- Naming follows PB prefix + Result suffix convention ✅
sdk-components.protoimport 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
left a comment
There was a problem hiding this comment.
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 (b7d9f82 → ae97bc5)
PurchaseParams → ItemPurchaseParams, purchase → item_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_idbare vs optional on result side- Slot-model assignments not enumerated
- No
REJECTED_INVALID_PARAMSverdict - 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
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: addsEU_ITEM_PURCHASE = 7to theExplorerUienum, representing the purchase confirmation modal.restricted_actions.proto: activates the extension point onOpenExplorerUiRequestwith aoneof paramscontaining anItemPurchaseParamsmessage (string urn). This lets the scene pass the item URN when requestingEU_ITEM_PURCHASE. The response message is unchanged — it returnsOPENED/WAS_ALREADY_OPEN/REJECTED_*immediately.restricted_actions.proto: addsoptional uint32 request_id = 2toOpenExplorerUiRequest. 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, and0is 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 byavatar_nametagwhile 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 aoneof status { Purchased, Dismissed, Failed }alongside the originalurn, the scene-ticktimestamp, and the echoedrequest_id. This follows the same pattern asPBExplorerUiEventsResult(1220) but keeps purchase-specific outcomes separate, since Purchased/Dismissed/Failed are not meaningful for otherExplorerUipanels.explorer_ui_events_result.proto: 1220 gains the sameuint32 request_id = 3echo, so panel lifecycle events are attributable to their call too.WAS_ALREADY_OPENsemantics 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
request_idis 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 plainuint32with0reserved — the explorer is the only writer, andoptionalthere would create two ways to say "no correlation".Failedis intentionally coarse (no error sub-types) to prevent wallet-balance probing via differential error analysis.Purchased) without breaking the wire format.EU_ITEM_PURCHASEwithparamsunset (or params on a mismatchedui) is deliberately left undefined for this iteration; aREJECTED_INVALID_PARAMSvalue can be added additively if experience shows it is needed.