A player accepts one rewarded-ad offer for 50 gems. The balance moves from 400 to 500. That does not, by itself, prove the ad SDK fired the same reward callback twice. It shows that the balance rose by twice the advertised reward. Inspect the grant ledger to distinguish two accepted writes from one write with the wrong amount, an unrelated balance change, or a display error.
To diagnose duplicate credits, trace every path that can change the wallet, separate ad lifecycle events from reward decisions, and enforce a stable grant key at the authoritative write. The examples below show the faulty path, the correction, and the tests that should keep the bug from returning. They exercise application logic; SDK integration, mediation behavior, and server-side verification signatures still require their own checks.
The central rule is simple:
For one player and one stable grant key, the authoritative wallet may accept the reward at most once.
That rule must hold even when callbacks arrive in an unexpected order, the same request is retried, two managers observe the same event, or many workers race at once.
Start with the wallet, not the callback count
Google's current Unity rewarded-ad guide separates the callback passed to RewardedAd.Show(...) from full-screen lifecycle events such as OnAdFullScreenContentClosed and OnAdFullScreenContentFailed. The same guide describes a rewarded ad as a one-time-use object and uses close or failure to prepare the next ad, not to define a second player reward. Google's Android guide similarly separates OnUserEarnedRewardListener from FullScreenContentCallback. It also notes that callback order can differ for mediated ads because the third-party source may determine the order. The current Android Next-Gen example likewise passes a reward listener to show(...) while keeping lifecycle in an ad-event callback. See Google's Unity rewarded guide and Android rewarded guide.
That documentation supports a clean ownership model:
| Signal or layer | What it can establish | What it should own in the application | What it cannot safely replace |
|---|---|---|---|
| Client reward callback | The SDK's client-side reward signal | Submit one candidate grant under a client-authority policy | Durable deduplication or server authentication |
| Full-screen close/fail | Ad presentation lifecycle | Restore UI, clear consumed state, load or request the next ad | Player currency, energy, revive, or loot credit |
| Impression, click, paid event | Measurement or monetization telemetry | Analytics | Reward eligibility |
| Verified SSV callback | A server callback whose signed parameters were validated | Validate/reconcile a client grant, or become the sole server grant authority | Automatic deduplication of repeated delivery |
| Idempotent grant store | Whether a stable key was already accepted | Accept once or reject as duplicate, atomically with the wallet update | Proof that the player was eligible or the request was genuine |
The last distinction matters. Idempotency is not authentication. A uniqueness rule can stop the same key twice. It cannot tell whether a new key was invented by a compromised client, whether the reward amount is allowed, whether the player owns the attempt, or whether an SSV signature is valid. Eligibility and authenticity must be checked by the authority model you choose; idempotency then prevents replay of an accepted transaction.
Open the reproducible bad and fixed paths
Open the interactive duplicate-reward demo.
The left panel deliberately gives both reward_earned and ad_closed permission to mutate the wallet. Running one normal completion produces two writes and a 100-gem balance from a 50-gem offer.
The right panel gives lifecycle events no wallet authority. The reward event submits a stable key such as player-42:revive:attempt-0001; an in-memory teaching ledger accepts that key once. A repeated reward event is rejected, close only records lifecycle, and the SSV button records validation without a second credit. Creating attempt-0002 permits a new legitimate 50-gem grant.
The demo is synthetic, but its JavaScript was exercised in actual headless Chromium rather than judged only by reading the source. The browser test reproduced the 100-gem bug, held the fixed balance at 50 through 32 same-key replays, checked all six orderings of reward, close, and SSV validation, accepted a second distinct attempt, ran a 390×844 viewport smoke check, and observed no browser console or page errors. Those results describe this standalone demo only; they are not device or SDK test results.
Draw the complete write graph
Do not begin by searching for OnUserEarnedReward and stopping at the first match. Search for every function that can change the authoritative value:
AddGems,CreditEnergy,GrantLoot,CommitRevive, and equivalent wallet methods;- helpers that complete an offer, level, revive, or purchase;
- event subscribers that react after an ad manager publishes “reward complete”;
- queued or retried API calls;
- SSV processors and reconciliation jobs;
- restore-on-resume code;
- admin or support adjustments that reuse the same grant endpoint.
For one deliberately chosen attempt, assign an attempt ID before the ad is shown and log every arrow from the button tap to the wallet commit. A useful trace looks like this:
reward_signal attempt=revive-781 source=client_reward
wallet_submit grant_key=player-42:revive-781 source=client_reward
wallet_accept grant_key=player-42:revive-781 400->450
ad_closed attempt=revive-781 grant_attempted=false
wallet_submit grant_key=player-42:revive-781 source=queued_retry
wallet_reject grant_key=player-42:revive-781 reason=duplicate
The rejection is valuable evidence. It shows that a second path exists while also proving that the second path cannot change the economy.
The duplicate paths to inspect first
Reward plus close
This produces the classic, nearly constant 2× symptom. The smallest deliberately wrong model is:
on_reward_earned -> wallet.add(50)
on_ad_closed -> wallet.add(50)
The fix is not “hope close arrives later.” Close must not own the reward at all.
Repeated subscriptions or duplicate manager instances
A handler attached each time an offer opens can accumulate across attempts. The visible pattern may be 1×, then 2×, then 3×. Another version is two live ad-manager objects, each subscribed once and each forwarding the same logical reward.
C# uses += to subscribe and -= to unsubscribe. Microsoft notes that anonymous handlers are difficult to unsubscribe unless their delegate is retained, and recommends unsubscribing when the subscriber is disposed. See Microsoft's event subscription guidance.
This is an illustrative lifecycle pair, not a complete ad integration:
void OnEnable() => rewardService.RewardCommitted += HandleRewardCommitted;
void OnDisable() => rewardService.RewardCommitted -= HandleRewardCommitted;
Symmetry alone is not sufficient. Log a manager instance ID, scene ID, and subscription count so that “one handler per object” does not hide “two objects.”
Two show routes or stale loaded objects
A disabled button prevents some rapid taps, but another input route, stale scene, or second controller can still present or process a second opportunity. Consume the application's loaded-ad reference before presentation and assign a distinct attempt ID to each accepted show request. The SDK's one-time-use object rule protects a particular ad object; it does not define your cross-object economic transaction.
Client credit plus server credit
Google describes rewarded SSV callbacks as URL requests sent to an external system and says they provide extra protection against spoofed client callbacks. The callback includes a transaction_id described as a unique identifier for each AdMob reward grant event. Google's guidance recommends immediate client reward with later validation for responsiveness, while noting that waiting for verified SSV can be appropriate when reward validity is critical and delay is acceptable. See Validate SSV callbacks — Unity.
Choose one explicit authority policy:
Client authority. The client reward signal submits the grant immediately. A cryptographically verified SSV callback validates or reconciles the existing transaction. It does not independently add the same reward again.
Server authority. The client shows a pending state. Only a verified SSV callback may submit the grant, with the provider transaction_id used in the idempotency key. Repeated delivery of the same verified transaction is rejected.
Do not run both as unconditional credit paths.
Retry, reconnect, or queue replay
A server can commit a grant while the client times out before receiving the response. The client retries; a background job later retries again. Google also says that if an SSV endpoint is unreachable or does not return HTTP 200, it can retry the callback up to five times at one-second intervals. See the SSV FAQ.
Retries are expected delivery behavior. A stable key turns them into harmless duplicate submissions. Generating a fresh random key on every retry defeats idempotency because every delivery looks like a new transaction.
Duplicate presentation without duplicate value
Sometimes the wallet has one accepted grant while two toasts, animations, sounds, or analytics events fire. Measure these separately:
- authoritative balance delta;
- accepted grant-record count;
- UI and analytics notification count.
A duplicate animation is still a bug, but it is not the same incident as a duplicate economic write.
Put the invariant where every path converges
A process-local boolean such as hasRewarded can reduce one symptom. It usually fails across restarts, two devices, background jobs, or two server workers. The grant-once rule belongs in the authoritative persistence boundary.
A practical key must be stable for retries and distinct for legitimate new opportunities. Common shapes include:
client authority: player_id + backend_issued_attempt_id
server authority: player_id + provider + verified_transaction_id
The database operation must combine two decisions:
- insert the grant record only if its unique key does not exist;
- increase the wallet only if that insert actually succeeded.
The example below makes the gate executable rather than leaving “only when the insert affected one row” as a comment. The wallet UPDATE is actually conditional. It is an excerpt from the included executable SQLite idempotency demo:
con.execute("BEGIN IMMEDIATE")
insert = con.execute(
"""
INSERT INTO reward_grants
(player_id, grant_key, reward_amount, source)
VALUES (?, ?, ?, ?)
ON CONFLICT(player_id, grant_key) DO NOTHING
""",
(player_id, grant_key, amount, source),
)
inserted = insert.rowcount == 1
if inserted:
update = con.execute(
"UPDATE player_wallets SET gems = gems + ? WHERE player_id = ?",
(amount, player_id),
)
if update.rowcount != 1:
raise RuntimeError("wallet row missing")
con.commit()
SQLite documents that ON CONFLICT ... DO NOTHING can turn a uniqueness violation into a no-op, and that explicit transactions group writes until COMMIT or ROLLBACK. Python's sqlite3 reference defines Cursor.rowcount as the number of rows modified by a completed INSERT, UPDATE, DELETE, or REPLACE statement; that is the explicit condition used above. See the official SQLite UPSERT documentation, transaction documentation, and Python 3.13 sqlite3.Cursor.rowcount reference.
The included test ran this exact conditional function on Python 3.13.5 with SQLite 3.46.1. A sequential case accepted key A, rejected a replay without changing the balance, and accepted key B. It then ran 12 fresh database rounds with 32 simultaneous same-key transactions per round. Every round ended with one accepted grant row and a 50-gem balance.
This is a compact persistence demonstration, not a universal schema. Adapt locking, isolation, error handling, ledger design, and wallet ownership to your production database. Preserve the invariant.
Log the grant decision, not only the ad event
For every accepted or rejected submission, record enough context to reconstruct the write graph:
grant_key
player_id_hash
placement
attempt_id
source # client_reward, verified_ssv, retry, reconcile
reward_type
reward_amount
eligibility_policy # client_authority or server_authority
verification_status # where applicable
accepted # true or false
rejection_reason
old_balance
new_balance
ad_response_id # when available
provider_transaction_id
manager_instance_id
queue_or_thread
occurred_at_utc
Avoid raw personal identifiers in telemetry. More importantly, do not let logging become the guard. The uniqueness decision and wallet update must remain authoritative even if logs are delayed or lost.
Run the full regression matrix
The package includes the same matrix as HB99_reward_regression_matrix_v2.csv. The standalone Java harness was compiled and run on OpenJDK 21.0.11. The independent Python model exhaustively checked 185,037 callback sequences. The SQLite concurrency case used real transactions rather than the in-memory model.
| Scenario | Sequence for one economic opportunity | Expected accepted grants | Expected wallet delta |
|---|---|---|---|
| Deliberate bug | reward, close; both write | 2 | +100 |
| Fixed normal completion | reward, close | 1 | +50 |
| Duplicate reward delivery | reward, same reward again | 1 | +50 |
| Alternate order | close, reward | 1 | +50 |
| Lifecycle only | failure, close | 0 | +0 |
| Two manager instances | manager A reward, manager B same-key reward | 1 | +50 |
| Queued retry | request, retry, retry with same key | 1 | +50 |
| Client authority with SSV | client reward, verified validation | 1 | +50 |
| Server authority with SSV | pending, verified transaction, same transaction replay | 1 | +50 |
| In-memory race | 32 simultaneous same-key submissions | 1 | +50 |
| Database race | 32 simultaneous same-key transactions | 1 | +50 |
| New legitimate opportunity | reward key A, reward key B | 2 | +100 |
| Duplicate presentation | accepted reward, second UI notification | 1 | +50 |
| Idempotency-scope check | two distinct unverified keys | 2 | +100 |
The final row is intentional. A bare idempotency store accepts two different keys, which demonstrates why deduplication cannot be presented as eligibility verification.
Use the symptom to choose the first investigation
| Observed pattern | First application path to inspect |
|---|---|
| Almost always exactly 2× | Reward plus close/completion helper, or client plus server credit |
| 1×, then 2×, then 3× | Accumulating subscriptions or manager instances |
| Duplicates after timeout, resume, or reconnect | Retried request without a stable key |
| Duplicates only under rapid interaction | Two show routes, two attempt creations, or stale ad state |
| Duplicates concentrated in mediation | Callback-order assumption; do not infer adapter fault from order alone |
| One balance change but two celebrations | Presentation subscriber duplication |
| Same provider transaction appears repeatedly | Replay handling missing at the server grant boundary |
| Different new keys all grant | Eligibility or authentication gap, not an idempotency failure |
Application-level release checklist
Before shipping the economy fix:
- There is one searchable authoritative function that commits rewarded-ad value.
- Close, failure, impression, click, paid, reload, and UI callbacks cannot directly change that value.
- Every opportunity receives a stable key before the first grant submission.
- Legitimate new opportunities receive new keys; retries reuse the original key.
- The grant record has an enforced uniqueness rule.
- The wallet update runs only after a new grant record is accepted, in the same transaction or equivalent atomic operation.
- Duplicate submissions are logged as rejected and leave the balance unchanged.
- Client-versus-server authority is written down; SSV is not a second unconditional credit path.
- Eligibility, amount, player ownership, and SSV signature checks are separate from idempotency.
- The complete matrix passes in the application layer, then the product team also runs its own SDK, platform, mediation, and device tests for the integration it actually ships.
The diagnostic question is no longer “Did a callback fire twice?” It is “How could the same economic opportunity reach the wallet twice, and why did the store accept it?” Once every path converges on one conditional grant decision, duplicate delivery becomes observable instead of expensive.
Sources
- Rewarded ads — Unity, Google for Developers, last updated September 3, 2026.
- Rewarded ads — Android (Legacy), Google for Developers, last updated September 3, 2026.
- Set up rewarded ads — Android Next-Gen, Google for Developers, last updated September 3, 2026.
- Validate server-side verification callbacks — Unity, Google for Developers, last updated September 3, 2026.
- How to subscribe to and unsubscribe from events, Microsoft Learn, last updated October 12, 2021.
- UPSERT, SQLite documentation, page updated April 11, 2024.
- Transaction, SQLite documentation, page updated February 18, 2026.
sqlite3.Cursor.rowcount, Python 3.13 documentation, last checked September 9, 2026.




