Currency System — Public API¶
This page defines the supported, stable public API for the RevFramework Currency System.
This is the contract
If something is not listed here, it is not supported — even if it’s accessible in code.
Audience: Developers integrating Currency into gameplay, UI, or persistence Scope: Runtime public API only (Editor / Teaching helpers excluded)
Quick Start (Ignore everything else if you want)¶
var svc = CurrencyResolve.ServiceFrom(this);
svc.Credit(player, new CurrencyId("gold"), 100);
That’s it.
Everything else on this page is optional.
Core Concepts¶
- Currency is service-first and stack-composed.
- All interaction happens via
ICurrencyServiceand related capability interfaces. - Currency values are stored as integer minor units (
long), never floats. - Cross-cutting behaviour (caps, audit, escrow, authority, idempotency, batching) is added via composition, not inheritance.
- All mutations return a
CurOpResultwith an explicit outcome code.
Service Entry Points¶
ICurrencyService¶
The canonical runtime interface for all currency interaction.
Supported operations:
EnsureWallet(owner)GetBalance(owner, currencyId)Credit(owner, currencyId, amount)Debit(owner, currencyId, amount)SetBalance(owner, currencyId, absoluteAmount)Transfer(from, to, currencyId, amount)
All amounts are minor units (long).
Resolution¶
Currency services are resolved via:
ICurrencyService svc = CurrencyResolve.ServiceFrom(context);
Resolution order:
- Published override (bootstrappers / tests / teachables)
- Scene-local
SceneCurrencyService
SceneCurrencyService is the default scene implementation, but gameplay code must not depend on it directly.
Always resolve services via CurrencyResolve.
If you cache the result, watch CurrencyResolve.PublishEpoch
The scene bootstrap wraps the raw wallet in caps, audit, authority, idempotency and batch events and publishes the result from Start(). Unity runs every Awake and OnEnable in a scene load before any Start, so a component that resolves in either gets the raw service — and keeps it, if it only re-resolves while null.
if (_svc == null || _epoch != CurrencyResolve.PublishEpoch)
{
_svc = CurrencyResolve.ServiceFrom(this);
_epoch = CurrencyResolve.PublishEpoch;
}
The epoch changes whenever the published stack is published, replaced or cleared, and never resets, so a value cached in one play session cannot compare equal in the next. Resolving per call, as the pickup effects do, needs none of this.
Composition & Factories¶
CurrencyFactories¶
The supported way to build and extend a currency stack without referencing internal types.
Common decorators:
WithCaps(inner, policy)WithAudit(inner)WithAuthority(inner, context)WithEscrow(inner)WithIdempotency(inner)WithBatchEvents(inner)
Recommended multiplayer stack:
svc = CurrencyFactories.WithCapsAuditAuthority(
baseSvc,
policy,
context
);
Order matters.
Combined helpers enforce recommended ordering, but if composing manually you are responsible for decorator order.
Batching¶
CurrencyBatching¶
Public batching wrapper that allows multi-operation work to emit a single batch event.
using var batch = CurrencyBatching.BeginBatch(svc);
// perform multiple operations
batch.Emit();
Batching:
- captures deltas during a block
- emits a single batch event on success
- detaches subscriptions automatically via
Dispose()
Batch emission only occurs when the service stack includes:
CurrencyFactories.WithBatchEvents(...)
Otherwise batching is a safe no-op.
Policies & Caps¶
CurrencyPolicy¶
Authoritative asset defining per-currency caps and global rules.
Supported runtime surface:
TryGetCapRule(CurrencyId, out CurrencyCapRule)RequireEscrowstatic Create(bool requireEscrow, params CurrencyRuleAuthoring[] rules)static Create(params CurrencyRuleAuthoring[] rules)
Building one in code¶
Authoring a policy asset is the recommended route — caps that live in code are caps a designer cannot open. Create exists for the cases where there is no project to author into: a test, a generator, a tool, a code-first project.
var policy = CurrencyPolicy.Create(
new CurrencyRuleAuthoring { id = "gold", min = 0, max = 10_000, mode = CapMode.Clamp });
The result is a runtime object rather than an asset, marked HideFlags.DontSave, and should be treated as immutable once built. Rules are copied, so the array you pass is never touched — which matters because CurrencyRuleAuthoring is a class and the policy sanitises rules in place, swapping an inverted min/max and clamping negatives.
CreateInstance plus field assignment is not an equivalent, even from inside the assembly
CreateInstance raises OnEnable, which builds the lookup cache from the rules present at that moment — none. Assigning rules afterwards leaves a cache that is empty but not null, and the lazy rebuild only fires when it is null. Every lookup then misses, silently and permanently.
Create runs the rebuild explicitly. Editor code that pokes the serialized list survives only because ApplyModifiedProperties raises OnValidate.
Ids are normalised on the way in — trimmed and lowercased — so " GoLd " resolves as "gold", exactly as an authored asset with the same text does.
CurrencyCapRule¶
Stable primitive returned by policies.
Fields:
minmax(0 = unlimited)mode(ClamporFail)
Runtime code must rely on this primitive rather than authoring rule containers.
CurrencyDefinition¶
Authoring asset for one currency: id, display name, icon, and formatting.
Supported runtime surface:
Id,DisplayName,Icon,DecimalPlaces,Formatstatic Create(string id, string displayName = null, int decimalPlaces = 0, string format = "{amount}")
Create builds a runtime-only definition for the same cases CurrencyPolicy.Create covers, and follows the same rules: HideFlags.DontSave, treat as immutable once built, authoring an asset is still the recommended route.
Two differences from the policy factory, both deliberate:
- The id is stored as given, not normalised.
Idis documented as normalised at use sites, so a code-built definition behaves exactly as an authored one carrying the same text — including when that text is a mistake. Normalising here would make the constructor disagree with the inspector. - There is no icon parameter. A
Spriteis an asset reference with no meaningful code-built form. Assign one through an authored asset if you need it.
decimalPlaces is clamped to 0–6, matching the inspector's own [Range], so code cannot build a definition the asset format has no way to represent.
Operation Results¶
CurOpResult¶
Returned by all currency mutations.
Fields:
SuccessCodeMessage(optional)
CurOpCode¶
Stable outcome codes — the complete set:
OkUnknownErrorInvalidArgsServiceMissingNotFoundInsufficientFundsOverflowBelowMinimumAboveMaximumUnauthorizedIdempotencyMismatchPartial
Partial means the money moved
It is the one failure code that reports a mutation which happened: part of the requested amount was applied and the rest was not. Success is false by design, as it is for InvOpCode.Partial, which is where it comes from — an item-backed wallet whose inner transfer moved some of the coins and not all of them. Treating it as a clean refusal and retrying moves the money twice; treating it as success reports a total the wallet does not hold. Read the balances.
These codes are intended for:
- UI feedback
- analytics
- deterministic branching
Events¶
CurrencyDelta¶
Emitted after a successful mutation.
Contains:
ownercurrencybeforeafter
Event Surfaces¶
Supported event surfaces:
ICurrencyService.OnWalletChangedICurrencyBatchEvents.OnWalletBatchChanged
Batch events emit once per successful multi-operation block when batching is enabled.
Audit (Optional Capability)¶
CurrencyAudit¶
Helpers for reading audit history when auditing is present.
CurrencyAudit.Get(svc, owner);
If auditing is not present, helpers return empty results.
Empty means "not audited", since 1.3.0¶
CurrencyAudit resolves the reader by walking the composed stack, so it finds an audit layer sitting under any number of decorators:
CurrencyFactories.TryGetAuditReader(svc, out var reader) // the same walk, if you want it directly
Before 1.3.0 the helpers were a single cast on the service you were handed. If the outermost wrapper did not re-expose ICurrencyAuditReader you got an empty list while the audit layer underneath recorded every entry — so empty was ambiguous between not audited and audited, but hidden, and a consumer given the service by someone else's bootstrap could not tell which.
| Wrapper | Re-exposes ICurrencyAuditReader on itself? |
|---|---|
WithAudit | Yes — this is the layer that provides it |
WithAuthority | Yes |
WithIdempotency | Yes |
WithBatchEvents | Yes |
WithCaps | No |
WithEscrow | No |
RequireEscrow guard | No |
The No rows no longer cost you the reader — that column now only tells you whether a plain svc is ICurrencyAuditReader cast succeeds, which is a fact about the type rather than about your audit. Reach for the helpers, or TryGetAuditReader, and the placement stops mattering.
Escrow is outermost by design, and that is still true
The shipped escrow factories — WithCapsAuditEscrow, WithCapsAuditEscrowAndPump, WithCapsAuditAuthorityEscrow and their pump variants — put escrow outermost so the stack exposes ICurrencyEscrow directly and satisfies CurrencyPolicy.RequireEscrow. That has not changed. What changed is that it no longer costs you audit reading as a side effect.
Keeping your own reference to the audited layer still works and is still the most direct thing to do when you composed the stack yourself:
var audited = CurrencyFactories.WithCapsThenAudit(inner, policy, auditCapacityPerOwner);
var svc = CurrencyFactories.WithEscrow(audited); // publish this
var entries = CurrencyAudit.Get(svc, owner); // now finds it either way
CurrencyAuditLive is stricter still
ICurrencyAuditEvents is implemented by AuditedCurrencyService alone — no wrapper re-exposes it, not even the ones that forward the reader. CurrencyAuditLive.Subscribe therefore attaches only when the audited service is itself the outermost layer. Any stack that wraps anything above WithAudit needs the same treatment: subscribe against the audited layer you kept a reference to.
Live Audit¶
using var sub = CurrencyAuditLive.Subscribe(svc, entry => { });
This only activates when the service stack implements ICurrencyAuditEvents.
Awaiters¶
CurrencyAwaiters¶
Async and coroutine helpers that wait for currency state changes.
Completion is event-driven; invalidation is not. The balance condition you are waiting for is raised by OnWalletChanged, so nothing polls for the thing you asked about. What is polled is whether the wait is still valid — owner destroyed, service gone — because neither of those raises an event. The async awaiters check that every 50 ms; the coroutine variants check it once per frame, which is what a coroutine can do.
Examples:
WaitForBalanceAtLeastAsyncWaitForAnyChangeAsyncWaitForDeltaAsync- predicate-based waits
Async continuations may resume off the Unity thread.
Coroutine versions always resume on the Unity thread.
Transactions¶
CurrencyTxn¶
Fluent transaction builder.
var r = CurrencyTxn.Begin(svc)
.Debit(player, gold, 250)
.Credit(player, gems, 5)
.Commit();
Guarantees:
- ordered multi-operation execution
- best-effort rollback on first failure
- optional single batch event
Rollback may be blocked by:
- policy
- authority
- escrow requirements
- idempotency
ICurrencyIdempotencyWindow¶
Optional capability present on a stack built with CurrencyFactories.WithIdempotency. Reached with CurrencyFactories.TryGetIdempotencyWindow(svc, out var window) rather than by casting — the decorators are internal and the capability may sit several layers down.
ClearForOwner(GameObject owner)ClearAll()
Why it exists: a load rewinds balances, and the window has to rewind with them¶
The window remembers the result of a request id so a retry short-circuits instead of applying twice. That is correct while a wallet only moves forward. An in-process RevSaveManager.Load moves it backwards, and the window does not follow on its own — so an id used before the load is answered from memory afterwards, applies nothing, and reports success, against a balance the restore put back.
That is not an exotic case: a request id is normally derived from the game state that issued it — a quest step, an encounter, a shop transaction — and a load rewinds exactly that state, so re-issuing the same id is the ordinary thing for a game to do.
The shipped participant already does this
CurrencySaveParticipant clears the window as part of its restore, so a project using it needs to do nothing. This capability is for a host that puts wallets back some other way — the same arrangement Crafting has, where CraftingSaveParticipant calls ClearAppliedCompletions for you and a host driving RestoreJobs directly calls it itself.
Do not call it for any other reason
Clearing the window while the ids in it can still legitimately arrive re-opens double application for exactly those requests — which is the failure the window exists to prevent.
Escrow¶
ICurrencyEscrow¶
Optional capability that enables hard-hold escrow.
Operations:
TryHoldCommitReleaseExpireStale
Escrow behaviour:
TryHolddebits immediatelyCommitfinalizes the holdReleaserefunds
If CurrencyPolicy.RequireEscrow is enabled and the guard is present,
direct Debit and Transfer operations fail unless the stack includes escrow.
WithCapsAuditAuthority has no escrow
The WithCapsAuditAuthority family does not include escrow. Enabling RequireEscrow on those stacks denies all spend (no escrow path). For a stack that is both authority-gated and escrow-capable, use CurrencyFactories.WithCapsAuditAuthorityEscrow(...) (or ...EscrowAndPump(...)), which places escrow outermost so the requirement is satisfiable and holds/spends still pass through the authority gate.
ICurrencyEscrowReadOnly¶
Inspection surface for UI and debugging.
Allows querying active holds without mutation.
Exchange¶
ICurrencyExchange¶
Stable interface for currency conversion.
Built via:
ICurrencyExchange ex = CurrencyFactories.BuildExchange(table);
Supports:
TryQuoteTryExchange
Execution performs debit then credit with best-effort rollback.
CurrencyExchangeTable¶
Authoring asset holding the rules between currency pairs.
Supported runtime surface:
TryGetRule(CurrencyId src, CurrencyId dst, out ExchangeQuoteRule rule)static Create(params Rate[] rates)- the nested
Rate(authoring) andExchangeQuoteRule(query) types
Two types, deliberately. Rate is what a designer fills in and carries the pair; ExchangeQuoteRule is what runtime code reads back and carries only the terms, because the pair is the key you looked it up by. The same split exists between CurrencyRuleAuthoring and CurrencyCapRule on the policy side.
Building one in code¶
var table = CurrencyExchangeTable.Create(new CurrencyExchangeTable.Rate
{
fromId = "silver", toId = "gold", rate = 0.1d, feePct = 10d, roundDown = true,
});
Same rules as CurrencyPolicy.Create: runtime-only, HideFlags.DontSave, immutable once built, and authoring an asset is still the recommended route — rates are economy balance, and balance that lives in code is balance a designer cannot open. The pair is matched trimmed and lowercased, and later rules win for a repeated pair.
Terms are stored as given — Create does not clamp
rate and feePct carry [Min(0)], but that is an inspector affordance rather than a rule the runtime enforces. The exchange math consumes whatever it is handed, and clamping at construction would make a code-built table behave differently from what the maths actually does.
A negative fee is not a bonus. TryQuote clamps the fee into [0, 1] before applying it, so a negative feePct behaves exactly like zero — the quote is the plain rate, not an uplift. Earlier wording here, in the changelog and in the factory's own remarks said it was a bonus; it never was, and the shipped test's own name says so. If you want an uplift, raise the rate.
This is the opposite choice from CurrencyDefinition.Create, which does clamp, and the difference is the type rather than the convention: nothing reads a definition's decimal range back, so out-of-range means nothing there.
Rate became public when Create did. That changed no serialized data — accessibility is not part of Unity's asset format, and the field names, which are, are unchanged; existing exchange-table assets load exactly as before.
Persistence¶
ICurrencyPersistence¶
Abstract persistence contract.
Helper utilities:
CurrencyPersistence.CaptureCurrencyPersistence.Restore
Scene utilities (JSON save etc.) may implement this interface but are not required.
Restore Semantics¶
⚠️ Restore is not atomic.
Snapshot lines are applied sequentially. If a later line fails, previously applied lines are rolled back in reverse order using best-effort live calls.
A rollback call can itself fail — against a cap policy that rejects the original balance, revoked authority, or an escrow hold on the same wallet. The returned result always describes the original failure, so it cannot also report a failed rollback. Each failed compensating write is logged as an error naming the currency, owner, and target balance.
When that happens the wallet is left partially restored. If your game cannot tolerate that, capture a snapshot before restoring and verify balances afterwards rather than relying on the result code alone.
Telling a refusal that wrote nothing from one that wrote something¶
The returned code says why the apply stopped, not whether anything was written first, and the two differ: a refusal on the first line leaves the wallet provably untouched, while one on a later line leaves it partially restored whenever the rollback above also failed. An overload reports the count:
var result = CurrencyPersistence.Restore(svc, owner, snapshot, out int appliedLines);
if (!result.Success && appliedLines == 0)
{
// Nothing was written. This wallet still matches the snapshot, so the caller may carry it.
}
appliedLines counts writes attempted and accepted, not net change — a line rolled back after a later failure still counted, because a failed rollback is exactly the case worth being conservative about. Everything else about the overload matches the one without it.
CurrencySaveParticipant reads this to classify a section: with no line written it reports the section as unapplied and offers it back as carryable, and a wholesale authority denial is that case. It used to treat any refusal from the apply loop as a mutation, which filed such a load as PartiallyApplied — a bucket the report deliberately does not offer for carry-over — under a message saying some wallets had restored when none had.
Escrow holds across a restore¶
On an escrow stack implementing ICurrencyEscrowSnapshotAware, the owner's open holds are invalidated once every line has been written, never on a refused restore. A hard-hold escrow debits at hold time, so a wallet captured with a hold open recorded the post-debit balance; writing that back and then releasing the hold would credit an amount the restored balance already accounts for. Invalidating after the writes prevents that mint without destroying the holds on a load the authority, a cap policy, or the validation pass declined — a refused restore leaves them live and releasable.
CompensationFailureReport — finding out that an unwind was incomplete¶
RevGaming.RevFramework.Core.Abstractions.Diagnostics.CompensationFailureReport
Every refund, put-back and rollback in the framework can itself be refused, and when it is, the owner is left holding value the caller has just reported as not having happened. That was reported only through DevDiagnostics — which compiles out of a release player, so a shipped build had no signal for it at all.
CompensationFailureReport.Failed += f =>
Telemetry.Record($"{f.System}/{f.Operation} refused ({f.Code}): {f.Detail}");
// or, at a checkpoint:
if (CompensationFailureReport.Any) FlagSaveForReview(CompensationFailureReport.Last);
- Raised synchronously, inside the compensation path, before the caller's refusal is returned.
- A throwing subscriber is caught and logged; it cannot disturb the unwind.
Count/Last/Anyare cleared byReset(), and by Unity at the start of each play session.- Reported by Currency, Economy and their adapters. It is a diagnostic channel, not a recovery mechanism: no result code changes, and doing nothing with it leaves behaviour exactly as it is.
Value Types¶
Stable primitives safe for storage and serialization:
CurrencyIdMoneyCurrencyDeltaWalletSnapshotWalletSnapshotLine
Supported Extensions¶
CurrencyServiceExtensions¶
Public extension helpers that do not expand the core interface.
Includes:
- audit-aware overloads with
reasonandsourceId - affordability / deficit helpers (
GetDeficit,TryGetDeficit,FirstShortfall,FormatDeficit) - transfer preview helpers
These extensions are part of the supported public API.
Explicitly Not Supported¶
Not public API
- concrete service implementations (
SceneCurrencyService, decorators) - internal namespaces (
RevGaming.RevFramework.Currency.Internal) - internal batching types
- casting to internal capability interfaces
- authoring rule containers
- direct access to audit buffers or escrow internals
Runtime Guarantees¶
Runtime guarantees
- Single currency operations are atomic.
- All mutations return a deterministic
CurOpResult. - Successful mutations emit
OnWalletChanged. - Optional capabilities activate only when composed.
Not Guaranteed¶
- Full transactional atomicity across arbitrary decorator stacks
- Network replication
- automatic escrow usage
- automatic rollback when policy/authority blocks an operation
TL;DR¶
TL;DR
If it’s not on this page, it’s not part of the supported Currency API.