Skip to content

Decode reward payload in RewardVerification poll response#6678

Merged
polmiro merged 9 commits into
mainfrom
rewardverification-poll-reward-payload
Apr 24, 2026
Merged

Decode reward payload in RewardVerification poll response#6678
polmiro merged 9 commits into
mainfrom
rewardverification-poll-reward-payload

Conversation

@polmiro

@polmiro polmiro commented Apr 23, 2026

Copy link
Copy Markdown
Member

Checklist

  • If applicable, unit tests
  • If applicable, create follow-up issues for purchases-android and hybrids

Motivation

The internal @_spi(Internal) Purchases.pollRewardVerificationStatus(clientTransactionID:) SPI today only exposes whether verification passed (.verified / .pending / .failed / .unknown).

This PR adds reward-payload support to the response shape and surfaces it through the SPI.

Description

Wire shape (typed nested)

Discussed with @peter-revenuecat to follow this typed nested approach.

{ "status": "verified", "reward": { "type": "virtual_currency", "code": "coins", "amount": 10 } }

The explicit type discriminator makes it possible to distinguish future reward kinds (e.g. physical items) without having to disambiguate by-shape.

New types (internal SPI)

Both under Sources/Ads/RewardVerification/, both @_spi(Internal) public, Sendable, Equatable:

  • VirtualCurrencyReward { code: String, amount: Decimal }
  • VerifiedReward { case virtualCurrency(VirtualCurrencyReward), .noReward, .unsupportedReward }

Per the codebase rule against new public @frozen enums, these stay on the SPI surface only — they don't widen the stable public API.

Decoder (RewardVerificationStatusResponse)

Decode is lenient on the reward: never fails the overall decode just because the reward subtree is missing or malformed.

Wire verifiedReward
status != "verified" nil
status == "verified", reward absent or null .noReward
status == "verified", reward present but not a JSON object .unsupportedReward + Logger.warn
reward.type == "virtual_currency" with valid code + amount .virtualCurrency(...)
reward.type == "virtual_currency" with malformed fields .unsupportedReward + Logger.warn
reward.type is anything else .unsupportedReward + Logger.warn

Unknown wire status values continue to map to .unknown with the existing Logger.warn (unchanged).

SPI mapping

RewardVerificationPollStatus.verified now carries a VerifiedReward. Purchases.pollRewardVerificationStatus(clientTransactionID:) returns .verified(response.verifiedReward ?? .noReward) (defensive fallback so a future decoder regression can't crash the SPI).

Tests

  • RewardVerificationStatusResponseDecodingTests — added cases for verified+virtual-currency, decimal precision, missing/null reward, non-object reward, unknown reward type, malformed virtual_currency, and non-verified statuses ignoring stray reward payloads.
  • BackendGetRewardVerificationStatusTests — extended testGetRewardVerificationStatusVerified to assert the reward payload reaches the callback.
  • PurchasesRewardVerificationTests — end-to-end SPI mapping for all VerifiedReward cases plus the defensive fallback. (Also fixed: this test file was previously not registered in RevenueCat.xcodeproj, so it was silently never running. Registered it and added a BasePurchasesTests.MockBackend convenience init that injects a MockAdsAPI so backend.adsAPI as? MockAdsAPI resolves.)
  • VirtualCurrencyRewardTests and VerifiedRewardTests — direct unit tests for the new types under Tests/UnitTests/Ads/RewardVerification/ (field storage, equality, decimal precision; case construction, equality, exhaustive switch).

Verification

  • swift build
  • xcodebuild test -only-testing:UnitTests/{RewardVerificationStatusResponseDecodingTests,BackendGetRewardVerificationStatusTests,PurchasesRewardVerificationTests,VirtualCurrencyRewardTests,VerifiedRewardTests}
  • swiftlint on changed files ✅
  • bundle exec fastlane run_api_tests — pending (couldn't run locally; relying on CI)

Note

Medium Risk
Changes the reward-verification response model and decoding logic to carry reward payloads through an internal SPI, which could affect ad-reward integrations if decoding/mapping is wrong. Impact is limited to @_spi(Internal) APIs and is covered by expanded unit tests for malformed/unknown payload handling.

Overview
Reward verification polling now returns reward details on success. The internal SPI RewardVerificationPollStatus.verified now carries a VerifiedReward, and Purchases.pollRewardVerificationStatus maps backend responses through with that associated payload.

Backend response decoding was expanded to parse the reward subtree leniently. RewardVerificationStatusResponse now decodes reward only for status == "verified", supports virtual_currency via new VirtualCurrencyReward, and downgrades missing/malformed/unsupported reward payloads to .noReward / .unsupportedReward while logging new backend warning strings.

Tests and project wiring were updated. Adds unit tests for the new reward types and decoding edge cases, updates backend and purchases reward-verification tests to assert reward propagation, and registers new test/source files in the Xcode project (including fixing PurchasesRewardVerificationTests being included in the test target).

Reviewed by Cursor Bugbot for commit 7bbdea2. Bugbot is set up for automated code reviews on this repo. Configure here.

@polmiro polmiro marked this pull request as ready for review April 23, 2026 10:30
@polmiro polmiro requested a review from a team as a code owner April 23, 2026 10:30
@polmiro polmiro requested review from a team, ajpallares and peterporfy April 23, 2026 10:30
@polmiro

polmiro commented Apr 23, 2026

Copy link
Copy Markdown
Member Author

@RCGitBot please test

polmiro added a commit that referenced this pull request Apr 23, 2026
Post-rebase fixup. The base PR (#6678) changed
`RewardVerificationPollStatus.verified` from a payload-less case to
`.verified(VerifiedReward)`. The adapter doesn't yet propagate the
poll-time reward through the pipeline (today the dispatcher attaches
the present-time reward to the outcome), so this commit just keeps the
adapter compiling against the new shape:

- `RewardVerificationPoller` switches on `status` and ignores the
  associated `VerifiedReward` payload, with a comment marking the
  follow-up to wire the poll-time payload through.
- Test stubs that constructed `[.verified]` literals now construct
  `[.verified(.noReward)]` since the polled payload is currently
  unused downstream.

No behavior change. Plumbing the poll-time reward through to
`RewardVerificationOutcome` (and dropping the now-redundant
`verifiedReward:` parameter from the dispatcher) is deliberately left
for a follow-up commit / PR.
polmiro added a commit that referenced this pull request Apr 23, 2026
`Sources/Ads/RewardVerification/VerifiedReward.swift` and `VirtualCurrencyReward.swift`
are introduced (with the more complete doc comments) by the base PR
#6678 (`rewardverification-poll-reward-payload`). During the rebase
conflict resolution for the "Decouple reward-verification outcome from
GoogleMobileAds.AdReward" commit, the wrong side of the add/add
conflict was taken, reverting the file contents to PR B's earlier
sparser docs.

Restore both files to base's content so PR B's diff against base for
these two source files is now net-zero — base owns them, PR B doesn't
touch them. API and behavior identical (the only difference between
the two versions was the doc comments and the `Created by` date).

The PR B-only test files
`Tests/UnitTests/Ads/RewardVerification/VerifiedRewardTests.swift` and
`VirtualCurrencyRewardTests.swift` stay; they remain valid against
base's source files (same API).
polmiro added a commit that referenced this pull request Apr 23, 2026
Post-rebase fixup. The base PR (#6678) changed
`RewardVerificationPollStatus.verified` from a payload-less case to
`.verified(VerifiedReward)`. The adapter doesn't yet propagate the
poll-time reward through the pipeline (today the dispatcher attaches
the present-time reward to the outcome), so this commit just keeps the
adapter compiling against the new shape:

- `RewardVerificationPoller` switches on `status` and ignores the
  associated `VerifiedReward` payload, with a comment marking the
  follow-up to wire the poll-time payload through.
- Test stubs that constructed `[.verified]` literals now construct
  `[.verified(.noReward)]` since the polled payload is currently
  unused downstream.

No behavior change. Plumbing the poll-time reward through to
`RewardVerificationOutcome` (and dropping the now-redundant
`verifiedReward:` parameter from the dispatcher) is deliberately left
for a follow-up commit / PR.
polmiro added a commit that referenced this pull request Apr 23, 2026
`Sources/Ads/RewardVerification/VerifiedReward.swift` and `VirtualCurrencyReward.swift`
are introduced (with the more complete doc comments) by the base PR
#6678 (`rewardverification-poll-reward-payload`). During the rebase
conflict resolution for the "Decouple reward-verification outcome from
GoogleMobileAds.AdReward" commit, the wrong side of the add/add
conflict was taken, reverting the file contents to PR B's earlier
sparser docs.

Restore both files to base's content so PR B's diff against base for
these two source files is now net-zero — base owns them, PR B doesn't
touch them. API and behavior identical (the only difference between
the two versions was the doc comments and the `Created by` date).

The PR B-only test files
`Tests/UnitTests/Ads/RewardVerification/VerifiedRewardTests.swift` and
`VirtualCurrencyRewardTests.swift` stay; they remain valid against
base's source files (same API).

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 6f2d6df. Configure here.

@polmiro polmiro force-pushed the rewardverification-poll-reward-payload branch from c0121f8 to 5b051bb Compare April 23, 2026 11:22
Base automatically changed from rewardverification-rename-merged to main April 23, 2026 11:57
@polmiro polmiro force-pushed the rewardverification-poll-reward-payload branch from 5b051bb to 4055f9b Compare April 23, 2026 12:08
Comment thread Sources/Ads/RewardVerification/VirtualCurrencyReward.swift Outdated
@polmiro

polmiro commented Apr 23, 2026

Copy link
Copy Markdown
Member Author

@RCGitBot please test

polmiro added a commit that referenced this pull request Apr 23, 2026
PR #6678 made `RewardVerificationPollStatus.verified` carry the granted
`VerifiedReward`. The adapter pipeline was discarding that payload and
accepting a parallel `verifiedReward:` argument on the dispatcher as a
placeholder for a future present-time `GoogleMobileAds.AdReward`
translation.

Forward the polled reward end-to-end:
- `PollResult.verified` now carries `VerifiedReward`; `Poller.run`
  extracts and forwards it.
- `Outcome.init(pollResult:verifiedReward:)` collapses to
  `init(pollResult:)` sourcing the reward from the verified case.
- `Dispatcher.run`/`dispatch` drop the `verifiedReward:` parameter.

Tests updated to encode the verified reward via the stub poll status,
and `PollerTests` verified-result matches tightened to assert the
propagated payload.

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

Looks mostly good. I have some comments, mostly about the Status and VerifiedReward, which could be converted into an enum for better representability and validation

Comment thread Sources/Ads/RewardVerification/Networking/RewardVerificationStatusResponse.swift Outdated
Comment thread Tests/UnitTests/Ads/RewardVerification/VerifiedRewardTests.swift Outdated
polmiro added 8 commits April 24, 2026 12:13
Plumbs the verified-reward payload from the backend response through the
internal `pollRewardVerificationStatus` SPI so RC ad adapters can dispatch
meaningful "verified" events with the granted reward.

* Adds `VirtualCurrencyReward` and `VerifiedReward` (`@_spi(Internal) public`)
  under `Sources/Ads/RewardVerification/`.
* Extends `RewardVerificationStatusResponse` to optionally decode a typed nested
  reward payload (`{ type, code, amount }`) on `verified` responses.
  - `reward` absent or null → `.noReward`
  - `type == "virtual_currency"` with valid `code`+`amount` → `.virtualCurrency`
  - any other `type` (or malformed `virtual_currency`) → `.unsupportedReward` + warn
* `RewardVerificationPollStatus.verified` now carries the `VerifiedReward`.
* `Purchases.pollRewardVerificationStatus` returns `.verified(reward)`.
* Test mocks injected via a new `BasePurchasesTests.MockBackend` convenience
  init so `backend.adsAPI` resolves to `MockAdsAPI`.
Co-locates the unit tests next to the types this PR introduces under
`Sources/Ads/RewardVerification/`:

- `Tests/UnitTests/Ads/RewardVerification/VirtualCurrencyRewardTests.swift`
  Covers field storage, equality (both fields must match), and decimal
  precision preservation.

- `Tests/UnitTests/Ads/RewardVerification/VerifiedRewardTests.swift`
  Covers `.virtualCurrency(_)` associated-value carrying, `.noReward`
  vs `.unsupportedReward` distinct-case identity, equality (matching
  associated reward required), and an exhaustive switch coverage test
  to guard against silently adding cases.

Both tests pass against the existing `Sources/Ads/RewardVerification/`
types and require no changes to them.

(Originally written on top of #6663; moved to this base PR so the type
and its tests ship together rather than splitting across PRs.)
Previously, `decodeVerifiedReward` silently returned `.noReward` for any
of: missing reward key, null reward, or non-object reward value (e.g. a
string/number/array). The first two are expected; the last indicates a
backend mismatch and should be surfaced.

Now distinguish:
- absent or null reward → `.noReward` (silent, expected)
- present but not a JSON object → `.unsupportedReward` + `Logger.warn`

This matches the existing convention in this file of logging warnings
when decoding unrecognized/malformed values into fallback cases.
Align reward payload modeling with backend semantics by decoding `amount` as Int and updating tests to reject fractional values as malformed rewards.
Make the reward payload part of the verified status case so invalid state combinations are unrepresentable and remove the verified fallback mapping.
Treat virtual-currency rewards with non-positive amounts as malformed payloads and return unsupported reward with warning logs.
Validate virtual-currency payloads require a non-empty code and treat empty code values as malformed unsupported rewards.
Drop the exhaustive switch smoke test in VerifiedRewardTests since it did not validate behavior and duplicated compiler guarantees.
@polmiro polmiro force-pushed the rewardverification-poll-reward-payload branch from 38c43d0 to 86bb476 Compare April 24, 2026 10:14
@polmiro

polmiro commented Apr 24, 2026

Copy link
Copy Markdown
Member Author

@RCGitBot please test

@polmiro polmiro requested a review from ajpallares April 24, 2026 10:54

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

Looks good to me! Just a nit!

Also I think the PR description is a bit stale. Would be worth updating it

Comment thread Tests/UnitTests/Ads/RewardVerification/VerifiedRewardTests.swift Outdated
Drop VerifiedReward tests that only mirrored compiler-synthesized Equatable behavior and keep behavior-focused coverage.
@polmiro

polmiro commented Apr 24, 2026

Copy link
Copy Markdown
Member Author

@RCGitBot please test

@polmiro polmiro merged commit a87d869 into main Apr 24, 2026
42 checks passed
@polmiro polmiro deleted the rewardverification-poll-reward-payload branch April 24, 2026 14:33
JZDesign pushed a commit that referenced this pull request Apr 24, 2026
* Replace print with Logger.error in ISODurationFormatter (#6691)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* Decode reward payload in RewardVerification poll response (#6678)

* Decode reward payload in RewardVerification poll response

Plumbs the verified-reward payload from the backend response through the
internal `pollRewardVerificationStatus` SPI so RC ad adapters can dispatch
meaningful "verified" events with the granted reward.

* Adds `VirtualCurrencyReward` and `VerifiedReward` (`@_spi(Internal) public`)
  under `Sources/Ads/RewardVerification/`.
* Extends `RewardVerificationStatusResponse` to optionally decode a typed nested
  reward payload (`{ type, code, amount }`) on `verified` responses.
  - `reward` absent or null → `.noReward`
  - `type == "virtual_currency"` with valid `code`+`amount` → `.virtualCurrency`
  - any other `type` (or malformed `virtual_currency`) → `.unsupportedReward` + warn
* `RewardVerificationPollStatus.verified` now carries the `VerifiedReward`.
* `Purchases.pollRewardVerificationStatus` returns `.verified(reward)`.
* Test mocks injected via a new `BasePurchasesTests.MockBackend` convenience
  init so `backend.adsAPI` resolves to `MockAdsAPI`.

* Add unit tests for VerifiedReward and VirtualCurrencyReward

Co-locates the unit tests next to the types this PR introduces under
`Sources/Ads/RewardVerification/`:

- `Tests/UnitTests/Ads/RewardVerification/VirtualCurrencyRewardTests.swift`
  Covers field storage, equality (both fields must match), and decimal
  precision preservation.

- `Tests/UnitTests/Ads/RewardVerification/VerifiedRewardTests.swift`
  Covers `.virtualCurrency(_)` associated-value carrying, `.noReward`
  vs `.unsupportedReward` distinct-case identity, equality (matching
  associated reward required), and an exhaustive switch coverage test
  to guard against silently adding cases.

Both tests pass against the existing `Sources/Ads/RewardVerification/`
types and require no changes to them.

(Originally written on top of #6663; moved to this base PR so the type
and its tests ship together rather than splitting across PRs.)

* fix: warn when reward verification reward value is not a JSON object

Previously, `decodeVerifiedReward` silently returned `.noReward` for any
of: missing reward key, null reward, or non-object reward value (e.g. a
string/number/array). The first two are expected; the last indicates a
backend mismatch and should be surfaced.

Now distinguish:
- absent or null reward → `.noReward` (silent, expected)
- present but not a JSON object → `.unsupportedReward` + `Logger.warn`

This matches the existing convention in this file of logging warnings
when decoding unrecognized/malformed values into fallback cases.

* Fix virtual currency reward amount type to integer.

Align reward payload modeling with backend semantics by decoding `amount` as Int and updating tests to reject fractional values as malformed rewards.

* fix: address review — couple verified status to reward

Make the reward payload part of the verified status case so invalid state combinations are unrepresentable and remove the verified fallback mapping.

* fix: address review — reject non-positive reward amounts

Treat virtual-currency rewards with non-positive amounts as malformed payloads and return unsupported reward with warning logs.

* fix: address review — reject empty reward currency code

Validate virtual-currency payloads require a non-empty code and treat empty code values as malformed unsupported rewards.

* fix: address review — remove non-actionable enum switch test

Drop the exhaustive switch smoke test in VerifiedRewardTests since it did not validate behavior and duplicated compiler guarantees.

* fix: address review — remove synthesized equatable assertions

Drop VerifiedReward tests that only mirrored compiler-synthesized Equatable behavior and keep behavior-focused coverage.

* Delete claude.yml workflow (#6688)

* Add workflowTrigger to ButtonComponent.Action (#6693)

* Add workflowTrigger case to ButtonComponent.Action

Mirrors Android's rename of Action.Workflow → Action.WorkflowTrigger
(purchases-android#3380). Decodes backend "type": "workflow" into
.workflowTrigger so workflow buttons are rendered (previously decoded
as .unknown and hidden) and track as "workflow_trigger" in analytics.

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

* Add tests for workflowTrigger action decoding and analytics value

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* Cache decoded images by file URL in `FileImageLoader`

`URL.asImageAndSize` (used by `FileImageLoader.loadFromCache`) previously
called `UIImage(contentsOfFile:)` / `NSImage(contentsOfFile:)` on every
access. A SwiftUI paywall with many `RemoteImage` instances (e.g. a looping
carousel that materialises multiple copies of each page) ends up hitting
this path hundreds of times because `StateObject(wrappedValue:)` evaluates
its closure on every host view re-init. UIKit/AppKit's internal caches
still cost ~0.5–1 ms per call, which adds up to ~100+ ms of main-thread
blocking for only a handful of unique URLs.

Introduce a process-wide `DecodedImageCache` backed by `NSCache<NSURL, _>`
so repeated lookups for the same URL become a dictionary hit. `NSCache`
evicts on memory pressure, so we don't pin images forever. Reads/writes
are serialised through a concurrent dispatch queue with a barrier on
writes to keep the cache thread-safe.

Made-with: Cursor

---------

Co-authored-by: Facundo Menzella <facumenzella@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Pol Miro <polmiro@gmail.com>
Co-authored-by: Cesar de la Vega <664544+vegaro@users.noreply.github.com>
This was referenced Apr 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants