Skip to content

Currency — System Guarantees Matrix

The behavioural contract

This page defines the behavioural contract of the Currency system.

No marketing. No implication. Just guarantees — and explicit non-guarantees.


Quick Navigation

  • Core Service
  • Caps
  • Authority
  • Escrow
  • RequireEscrow Guard
  • Idempotency
  • Batch Events
  • Transactions
  • Hold Transactions
  • Persistence
  • Exchange
  • Awaiters
  • UI
  • Definitions
  • Public API
  • Non-Guarantees
  • Final Summary

1. Core Service

ICurrencyService

Aspect Guarantee
Storage Per-owner (GameObject) balances keyed by CurrencyId
Units Integer minor units (long)
Threading Synchronous. ❌ Not thread-safe — call from the Unity main thread
Mutation result Always returns CurOpResult
Events Emits OnWalletChanged when a mutation actually changes the stored balance
Successful no-op Returns Ok and emits nothing — a Credit of 0, a Debit of 0, or a SetBalance to the current value
Atomicity ✔ Single-operation atomic
Cross-operation atomicity ❌ Not guaranteed

2. Caps

CappedCurrencyService

Aspect Guarantee
Min/Max enforcement ✔ Per-currency
Clamp mode Adjusts to bounds
Fail mode Returns BelowMinimum / AboveMaximum
Applies to Set, Credit, Debit, Transfer
Transfer semantics Evaluated against policy on both source and destination before applying; fails or clamps accordingly
Cross-operation atomicity ❌ Not guaranteed

3. Authority

AuthorityCurrencyService

Aspect Guarantee
Gated ops Credit, Debit, SetBalance, Transfer — the four core overloads and the four audit-aware ones
Read ops gated ❌ No (GetBalance, EnsureWallet allowed)
Missing binder Returns Unauthorized
Denied mutation Returns Unauthorized
Binder throws Exception logged via Debug.LogException; returns Unauthorized; nothing mutated. A gate that cannot answer is a gate that denies — Currency is the only system that converts, the others let it propagate before any mutation
Scope Per scene, resolved from the context passed to WithAuthority (the CurrencyServiceBootstrap in the shipped stack), never from the owner. The first usable authority found answers for every owner in the scene; the owner is an argument to HasAuthority, which is where a per-owner rule goes
Which question a two-party path asks HasAuthorityTransfer for ICurrencyService.Transfer and CurrencyTxn.Transfer. Escrow-backed movements (CurrencyHoldTxn.HoldTransfer, CurrencyHoldCraftingAdapter) and TryExchange are decided as Debit on the source plus Credit on the destination, because escrow composes outside the authority layer
Asks per operation One per leg, plus one per rollback leg. An exchange asks twice and again to refund; a purchase asks per line. Implementations must be pure, repeatable predicates
Multiplayer logic ❌ Not provided
Replication ❌ Not provided

4. Escrow

EscrowCurrencyService

Aspect Guarantee
TryHold Immediate debit + token
Commit Logical finalize (no additional debit)
Release Refund credit
TTL Optional expiry
ExpireStale Refund or drop destroyed owners
Hard atomic multi-op ❌ Not by itself (single-hold atomic only)
Holds survive a save/load across processes No — and the held amount does not come back
Holds survive a restore in the same process ❌ No — invalidated, and the balance is not touched
Holds survive their owner being destroyed ❌ No, dropped silently

Saving while a hold is open loses the held money

TryHold debits immediately and hands back a token. A save captures balances only, so a snapshot taken while a hold is open records the already-reduced balance and nothing records the token that would have returned it. Load that file and the money is gone — not refunded, not still held, because the hold does not exist in the loaded process.

This is not a limitation of one save component. No currency persistence surface carries hold state, because a hold is keyed by a runtime token and an owner GameObject, and neither survives a reload.

If your game can save while a hold is open — a timed shop reservation, a pending trade — resolve holds before saving: Commit them, or Release them so the amount returns to the wallet the snapshot then captures.

Restoring in the same process invalidates open holds — it does not release them

The row above is about loading in a fresh process, where there are no holds to speak of. A quickload, a checkpoint reload or a retry restores into a process where the holds are still live, and that used to create money: the snapshot recorded the post-debit balance, restoring it put that balance back, and the eventual Release credited the held amount on top of a balance that already accounted for it.

A restore now drops the owner's holds without crediting anything, because releasing is the credit that mints. A caller still holding a token gets EscrowOpCode.Invalidated from Commit and Release — distinct from UnknownToken, because the money is accounted for rather than lost.

A custom escrow only participates if it implements ICurrencyEscrowSnapshotAware. One that does not is left alone and the restore says so in the log, rather than assuming it cooperated.


5. RequireEscrow Guard

Aspect Guarantee
When RequireEscrow == true Debit/Transfer require an escrow layer to be present below the guard
Escrow missing Deterministic ServiceMissing
Direct un-held Debit/Transfer ✔ Still succeed once escrow is present
Affects Credit ❌ No
Adds escrow capability ❌ No (guard only)

The guard is a capability check, not a spend path. It refuses Debit/Transfer when nothing below it implements ICurrencyEscrow; once an escrow layer exists anywhere below, both forward unchanged, so a caller can still debit directly without ever taking a hold. If every spend in your game must go through hold → commit, that is a rule for your own call sites — no decorator in the stack enforces it.


6. Idempotency

IdempotencyCurrencyService

Aspect Guarantee
Scope Audit-aware overloads only
Key format "|rid:<token>" suffix in sourceId
Duplicate request Short-circuits to Ok("Idempotent replay")
Non-audit calls Pass through unchanged
Storage Per-owner fixed-capacity ring buffer
Replay window The last N audit-aware ops for that owner (default 128) — not all of them
Past the window ❌ A retried id is treated as fresh and applies again
Cross-process dedupe ❌ No
Survives a reload ❌ No — in-memory, so an id issued before a save is fresh after it

Note:
Replayed requests may not emit wallet events or audit entries, depending on decorator order.

The window is a window, not a memory. Recent request ids live in a fixed-capacity ring, so once an owner performs capacityPerOwner further audit-aware operations, the oldest id is forgotten and a retry carrying it applies a second time. Size the capacity against how many wallet operations one owner can perform between a request failing and being re-issued — not against session length. Editor and development builds warn once per service the first time a window fills, so this is visible rather than theoretical.

An unbounded set is deliberately not offered: it would trade a bounded, documented replay window for unbounded memory growth in exactly the busy session that reaches the limit.


7. Batch Events

Aspect Guarantee
Requires WithBatchEvents
Batch capture Via CurrencyBatch
Emit control Caller decides Emit()
Per-op OnWalletChanged ✔ Not suppressed — fires exactly as it would unbatched
Auto-emit on dispose ❌ No
Multi-op collapse ✔ Single aggregated batch event (when emitted)

Batching affects event emission only, not mutation behaviour.


8. Transactions

CurrencyTxn

Aspect Guarantee
Multi-op staging ✔ Yes
Pre-sanity check Funds + overflow only
Rollback on failure ✔ Best-effort reverse ops
Uses composed stack ✔ Yes
Strong atomicity ❌ Not guaranteed
Escrow-aware ❌ No

9. Hold Transactions

CurrencyHoldTxn

Aspect Guarantee
Requires escrow ✔ Yes
Phase A Acquire holds first
Phase B Preflight credit effects
Phase C Apply credits
Phase D Commit tokens
Failure before credit phase ✔ Net-zero guarantee
Failure after credit phase ✔ Best-effort unwind
True atomicity ❌ Not guaranteed

10. Persistence

Aspect Guarantee
Snapshot type Absolute balances
Which currencies a snapshot covers Exactly the ids the caller passes to CurrencyPersistence.Capture
Currency discovery ICurrencyWalletQuery.TryGetHeldCurrencies, when the service implements it
CurrencyJsonSave / the save participant ✔ Configured ids plus whatever the wallet holds — see note
Restore behaviour Sequential SetBalance
Currency in the wallet but not in the snapshot ❌ Left alone — a restore sets, it does not clear
Rollback ✔ Best-effort per owner
WalletSnapshot through JsonUtility Does not round-trip — flatten it yourself, see note
Batch emit ✔ Single event when supported
Authority applies ✔ Yes
RequireEscrow applies ❌ No (restore uses SetBalance, which the guard does not gate)
Escrow holds used ❌ No
Cross-owner atomicity ❌ No

A currency left out of a save used to be free money

CurrencyPersistence.Capture writes one line per id it is handed and nothing else — it cannot know what it was not told about. Every caller therefore worked from a curated list, and naming only some of the currencies was worse than it looked: an unlisted one was neither written to the save nor set by a restore, so its balance simply survived a load. Spend a premium currency on an item, load an earlier save, and the item is gone but the money is back. Both operations reported success.

ICurrencyWalletQuery.TryGetHeldCurrencies is what makes the question answerable, and both shipped save paths — CurrencyJsonSave and the save system's CurrencySaveParticipant — now capture the configured ids plus whatever each wallet actually holds. The configured list is still a floor rather than a fallback: a wallet has no entry for a currency it has never held, so only the list can write one at zero, and that line is what lets a restore put a wallet back to zero.

CurrencyPersistence.Capture itself is unchanged and still captures exactly what you pass it. A service that cannot enumerate answers "cannot tell", never "holds nothing", and the callers fall back to their configured ids alone.

A restore sets balances; it never removes one

Only the currencies present in a snapshot are written. A currency the wallet holds that the snapshot does not mention keeps its current balance — so one first acquired after the save was written survives loading that save. Zero the wallet yourself first if a load has to be the whole truth for it.

WalletSnapshot cannot be saved with JsonUtility — it looks like it can

Everything points the obvious way and the obvious way does not work. The type is [Serializable], it is documented for save/load, and its backing field name is deliberately kept stable for JsonUtility. But WalletSnapshotLine is a readonly struct with readonly fields, and Unity's serialiser skips readonly fields entirely.

So a round trip gives you the right number of lines with every field at its default. The save looks correct written to disk, and the load fails with Invalid snapshot line — and because CurrencyPersistence.Restore validates a whole snapshot before applying any of it, that single defect refuses every balance in the wallet, not just the line that caused it.

[Serializable] does not save you here: it makes the container serialisable, not its readonly contents.

Flatten to your own type, converting each CurrencyId to a string. Both shipped save paths do exactly that — CurrencyJsonSave and the save system's CurrencySaveParticipant each keep a private line DTO for it. If you are writing your own persistence, copy that shape rather than assuming this has been fixed; immutability is the right design for a captured value and is staying.


11. Exchange

Aspect Guarantee
Quote Non-mutating calculation with rule constraints (rate, min/max, fee, rounding)
Execution Debit then Credit
Credit failure rollback ✔ Best-effort
Uses composed stack ✔ Yes
True atomic swap ❌ No
Cap/Authority enforced ✔ Yes

12. Awaiters

CurrencyAwaiters

Aspect Guarantee
Waiting model Event-driven with lightweight fallback polling for invalidation
Async versions ❌ Continuations are not marshalled to the main thread — treat them as off-thread and marshal before calling Unity APIs
Coroutine versions ✔ Always resume on main thread
Service absence Await returns failure / default

13. UI

Aspect Guarantee
Event-driven updates ✔ Yes
Service resolution Via CurrencyResolve
Auto-rebind ✔ Yes (service swap detection)
Modifies state ❌ Never
Multiplayer-safe default ❌ Owner fallback is SP convenience

14. Definitions

Aspect Guarantee
Formatting only ✔ Yes
Affects storage ❌ No
CurrencyId contract Stable primitive
Required for runtime ❌ No

15. Public API

Aspect Guarantee
Stable supported helpers ✔ Yes
Exposes internal types ❌ No
Batch wrapper safe ✔ Yes
TransferPolicyProviders Preview logic only

Non-Guarantees

Currency does not guarantee

  • ❌ True multi-operation atomicity across decorators
  • ❌ Network replication
  • ❌ Server authority enforcement
  • ❌ Deterministic rollback across policy + authority + idempotency
  • ❌ Automatic escrow integration (must compose explicitly)
  • ❌ Cross-scene ownership safety. Wallets are keyed by GameObject; persistence maps owners via StableId.Id. Adding a StableId is necessary but not sufficient — it is stable for scene-authored objects only. A runtime-spawned object gets a different id every launch unless you call StableId.AssignId with a durable value, and duplicating an object copies its id, so two wallets can claim one identity. The Save system's guarantees matrix carries the full identity contract; Tools ▸ RevGaming ▸ RevFramework ▸ Validate ▸ Duplicate Stable Ids finds collisions.
  • ❌ Strong transactional guarantees under arbitrary decorator reorder
  • ❌ A WalletSnapshot that survives JsonUtility — its lines are readonly structs, which Unity skips; flatten to your own type, as both shipped save paths do. See §10.

Final Summary

Derived view

This table summarises the sections above. Where the two disagree, the sections are authoritative — they carry the qualifiers this table cannot.

Layer Strong Guarantee Best Effort Not Guaranteed
Single operation
Multi-op rollback (CurrencyTxn)
Escrow holds
Persistence atomicity (per owner)
Cross-stack atomicity
Multiplayer replication

System Philosophy

Currency is

  • Deterministic
  • Explicit
  • Decorator-composed
  • Service-driven
  • Honest about guarantees

It is not

  • A database
  • A networking framework
  • A financial-grade ACID system