Skip to content

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 ICurrencyService and 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 CurOpResult with 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:

  1. Published override (bootstrappers / tests / teachables)
  2. 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)
  • RequireEscrow
  • static 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:

  • min
  • max (0 = unlimited)
  • mode (Clamp or Fail)

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, Format
  • static 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. Id is 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 Sprite is 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:

  • Success
  • Code
  • Message (optional)

CurOpCode

Stable outcome codes — the complete set:

  • Ok
  • UnknownError
  • InvalidArgs
  • ServiceMissing
  • NotFound
  • InsufficientFunds
  • Overflow
  • BelowMinimum
  • AboveMaximum
  • Unauthorized
  • IdempotencyMismatch
  • Partial

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:

  • owner
  • currency
  • before
  • after

Event Surfaces

Supported event surfaces:

  • ICurrencyService.OnWalletChanged
  • ICurrencyBatchEvents.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:

  • WaitForBalanceAtLeastAsync
  • WaitForAnyChangeAsync
  • WaitForDeltaAsync
  • 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:

  • TryHold
  • Commit
  • Release
  • ExpireStale

Escrow behaviour:

  • TryHold debits immediately
  • Commit finalizes the hold
  • Release refunds

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:

  • TryQuote
  • TryExchange

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) and ExchangeQuoteRule (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.Capture
  • CurrencyPersistence.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 / Any are cleared by Reset(), 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:

  • CurrencyId
  • Money
  • CurrencyDelta
  • WalletSnapshot
  • WalletSnapshotLine

Supported Extensions

CurrencyServiceExtensions

Public extension helpers that do not expand the core interface.

Includes:

  • audit-aware overloads with reason and sourceId
  • 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.