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 viaStableId.Id. Adding aStableIdis necessary but not sufficient — it is stable for scene-authored objects only. A runtime-spawned object gets a different id every launch unless you callStableId.AssignIdwith 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 Idsfinds collisions. - ❌ Strong transactional guarantees under arbitrary decorator reorder
- ❌ A
WalletSnapshotthat survivesJsonUtility— 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