# Design: @suaveplan/currency-provider-virtual > Non-normative. The normative requirements live in > `./specs/currency-provider-virtual/spec.md`. The cross-cutting > foundation-with-sibling-plugins architecture is documented once in > `openspec/archive/2026-05-17-slim-currency-providers/design.md` — this > document covers only what is specific to the virtual sibling. ## Shape ```text @suaveplan/currency-provider-virtual └── src/ ├── index.ts # explicit named barrel ├── error-codes/ │ ├── error-codes.ts # re-exports UNKNOWN_CURRENCY helper │ └── error-codes.md └── provider/ ├── provider.ts # VirtualCurrencyProvider class ├── provider.types.ts # rate-table & options types ├── provider.test.ts # all behaviour incl. UNKNOWN_CURRENCY └── provider.md ``` ## Rate-table storage Rates are stored as a nested `Map>`. Pair lookup is O(1); enumeration for `fetchRates` is O(n) over the inner map of the requested base. Storing the table as nested `Map`s rather than a flat `Map<"${from}->${to}", number>` keeps `listCurrencies` cheap (just the outer key set) and avoids string-concatenation collisions when currency codes contain hyphens (`USDC-GOLD`, `GAME-1-COIN`). Insertion replaces an existing rate silently — `setRate` is upsert, mirroring the upstream behaviour. This is intentional: virtual currencies are operator-controlled and "the operator changed the rate" is the normal flow, not an error. ## Triangulation is deliberately absent The upstream provider derived `rates[base]` for non-default bases by inverting the stored rate (`1 / baseVc.rate`) and computing cross-rates (`otherRate / baseVc.rate`). Genesis pushes that into `CurrencyConverter`: the provider returns the rates it has, the converter triangulates via the cache. This keeps two responsibilities separate and avoids the upstream's implicit assumption that every virtual currency converts through a single shared `EUR` base. Callers who want bidirectional rates pre-populate them explicitly — usually trivial in a game-economy seeder. ## Clock injection `Clock = () => number` from `@suaveplan/types` (ms since epoch). The constructor accepts an optional `clock` (default `() => Date.now()`); every `fetchRates` result stamps `timestamp = clock()`. Tests pass a `FakeClock` adapter (`() => fakeClock.now()`) for deterministic timestamps without monkey-patching `Date`. ## Error handling `fetchRates({ base })` consults the rate table for `base`. If `base` is not present, the provider throws `CurrencyProviderError(CURRENCY_PROVIDER.UNKNOWN_CURRENCY)` with `providerName: "virtual-currency"`. This is the only error path: `setRate` upserts, `removeRate` is idempotent (returns `false` for missing pairs), `clear` always succeeds. `UNKNOWN_CURRENCY` was added to the slim-core registry on the original 2026-05-17 land and is the canonical typed error for "asked about a currency the provider doesn't know." ## Universal-tier purity - No `node:` / `bun:` / DOM imports anywhere in `src/`. - No `fetch`, no network, no `FetchLike` consumption — this is the entire point of the package. - `sideEffects: false` — constructing the provider never mutates global state. (Contrast with the upstream form which called `@suaveplan/money` `registerCurrency` from the constructor, a hidden side effect that broke tree-shaking.)