Skip to content

Fix SubscriptionStatus property dropping entitlements in both directions#5

Open
bilck wants to merge 1 commit into
superwall:mainfrom
bilck:fix/subscription-status-entitlements
Open

Fix SubscriptionStatus property dropping entitlements in both directions#5
bilck wants to merge 1 commit into
superwall:mainfrom
bilck:fix/subscription-status-entitlements

Conversation

@bilck

@bilck bilck commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

The C# SubscriptionStatus property (Runtime/Superwall.cs) loses the entitlement list on both sides of the bridge:

  • Setter serializes only {"type": "Active"} — it never sends the entitlements. Both native bridges (SuperwallUnityBridge.swift and SuperwallUnityBridge.kt) already parse an "entitlements" array from this payload; C# just doesn't include it.
  • Getter ignores the "entitlements" array native serializes and always returns CreateActive with an empty list.

Either defect alone breaks any code that compares or re-asserts entitlements. Together they produce an infinite feedback loop right after a purchase (order of events below).

Order of events causing the bug

Setup: the app keeps a backend (cross-platform) entitlement source and merges it into Superwall client-side, as recommended when a backend is the source of truth — i.e. on every subscriptionStatusDidChange it unions backend entitlements into the SDK's current status and writes the result back, skipping the write when the sets are already equal.

  1. The user purchases on a Superwall paywall. The native SDK grants the entitlement, e.g. .active([premium]), and fires subscriptionStatusDidChange.
  2. The app's merge handler runs. It reads Superwall.Shared.SubscriptionStatus — but the getter drops the entitlement list, returning Active [] instead of Active [premium].
  3. The merge unions in the backend's premium and concludes the SDK is missing it (it isn't — the getter hid it). The "sets are equal, skip the write" exit can never trigger, so the app writes Active [premium] back.
  4. The setter drops the entitlement list, sending {"type": "Active"} to native. The native bridge parses no "entitlements" array and sets .active([]).
  5. The native SDK normalizes active-with-no-entitlements to inactive. The entitlement granted by the purchase in step 1 is now wiped.
  6. That status change fires subscriptionStatusDidChange(to: inactive) back into Unity.
  7. The merge handler runs again: current reads as Inactive, backend still says premium, so it writes Active [premium] again → step 4. Steps 4–7 repeat forever.

Reproduced on iOS sandbox with SDK 0.2.4: a single purchase produced ~45,000 loop iterations within minutes, with the app effectively frozen in the write/echo cycle:

subscriptionStatusDidChange → merge: current: Inactive; backend: [premium]; write Active [premium]
subscriptionStatusDidChange → merge: current: Inactive; backend: [premium]; write Active [premium]
... (~45k times)

Fix (C#-only; no native changes needed)

  • The setter now serializes the active entitlement ids in the shape both native parsers already read ([{"id": ...}]), and lowercases the type to match the native serializers.
  • The getter now reuses BridgeCallbackHandler.DeserializeSubscriptionStatus (privateinternal, same assembly) so entitlements survive the round trip.

With the round trip intact, step 2 reads Active [premium], the merge sees equal sets, skips the write, and the loop never starts; a genuinely needed write (backend knows an entitlement the SDK doesn't) converges after one echo.

Test plan

  • iOS Development build + sandbox account: after a paywall purchase, the entitlement merge converges in one pass (no repeated subscriptionStatusDidChange echo), and the purchased entitlement remains active in the native SDK after app restart.
  • Superwall.Shared.SubscriptionStatus = .active([...]) followed by a read returns the same entitlement ids.

🤖 Generated with Claude Code

The C# SubscriptionStatus property lost the entitlement list on both
sides of the bridge:

- The setter serialized only {"type": "Active"}. Both native bridges
  (SuperwallUnityBridge.swift / SuperwallUnityBridge.kt) already parse an
  "entitlements" array from this payload, but C# never sent it, so native
  received .active([]) - which the native SDKs normalize to inactive and
  answer with a subscriptionStatusDidChange(to: inactive) echo.
- The getter ignored the "entitlements" array native serializes and
  always returned CreateActive with an empty list.

Impact: any app that re-asserts entitlements on
subscriptionStatusDidChange (e.g. the documented client-side
cross-platform entitlement merge) enters an infinite set -> inactive ->
set feedback loop right after a purchase, and the entitlement natively
granted by the paywall purchase is wiped. Reproduced on iOS
sandbox/StoreKit with SDK 0.2.4.

Fix:
- Setter serializes the active entitlement ids (the shape both native
  parsers already read) and lowercases the type to match the native
  serializers.
- Getter reuses BridgeCallbackHandler.DeserializeSubscriptionStatus
  (private -> internal, same assembly) so entitlements survive the
  round trip.
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.

1 participant