Currencies
AstralEconomy is multi-currency by design: every account row is keyed on (uuid, currency), not just uuid, and the entire currency catalogue is config-driven from currencies.yml. There is no code change needed to add, rename, or retire a currency — only a config edit and /eco reload (see Reloading).
currencies.yml structure
The file is a single currencies map, keyed by an internal id, whose values each deserialize to an EconomyCurrency record. It is loaded alongside config.yml on startup and re-read by /eco reload — see Configuration.
Shipped default:
default is a transferable, leaderboard-tracked currency starting new accounts at 1000. astralys is a second, non-transferable currency starting at 0 with no leaderboard.
Currency fields
YAML key | Java field | Type | Required | Description |
|---|---|---|---|---|
|
| String | Yes | The currency's identifier, matched case-insensitively wherever a currency is looked up by id ( |
|
|
| Yes | Display name. Exposed as |
|
|
| Yes | Currency symbol. Exposed as |
|
| double | Yes (shipped file always sets it) | Starting balance minted into a newly provisioned account row for this currency — see |
|
| boolean | Yes (shipped file always sets it) | Whether |
|
| boolean | No — defaults to | Marks this the network's default currency — see The default currency. |
|
| String, nullable | No — defaults to | Pushes this currency's player-balance changes to an AstralCore leaderboard — see |
astralys in the shipped file demonstrates that default and leaderboard-id can simply be left out of an entry rather than written as default: false/ with no leaderboard-id key.
The default currency
Exactly one currency should be marked default: true. CurrenciesConfiguration exposes two accessors over the catalogue:
findDefaultCurrency()returns anOptional<EconomyCurrency>— empty when no currency is marked default. This is the accessor every player-facing and SPI path uses, so a missing default degrades gracefully instead of throwing:/balance(no argument) — warns the player withbalance-no-default-currencyand logs an error./pay— warns the sender withpay-no-default-currencyand stops before touching the ledger.Every no-currency overload of the
EconomyServiceSPI (deposit(uuid, amount),withdraw(uuid, amount),getBalance(uuid),hasBalance(uuid, amount),transfer(from, to, amount), …) — resolvescurrency == nullto the default id and fails closed (false/ empty /0.0) rather than guessing when none is configured.EconomyAPI.loadTown(s)/unloadTown(s)— each town pin operates on the default currency; with none configured,load*is a logged no-op andunload*does nothing.
defaultCurrency()returns theEconomyCurrencydirectly, throwingIllegalStateException("No default currency configured")when none exists. Reserve this for a path where a missing default is genuinely unrecoverable, not one that must degrade gracefully.
EconomyCurrency implements ComplexPlaceholder, so a currency (including the default) also exposes its default flag as %currency_default% — see Currencies as placeholders.
transferable
transferable gates only the player-initiated /pay command: PayCommand checks currency.transferable() and, if false, rejects the payment with pay-untransferable before any ledger call is made.
The admin /eco set//eco add|give//eco take subcommands call TransactionService directly and do not consult transferable at all — an operator can always move an untransferable currency. astralys in the shipped file is untransferable for exactly this reason: it is meant to move only through admin edits (or other plugins via the SPI, which likewise does not check transferable), never player-to-player.
leaderboard-id
When leaderboard-id is set and non-blank, every successful player-account mutation (deposit, withdraw, or either leg of a transfer — a set still updates the leaderboard the same way, since it goes through the same post-mutation hook) pushes the account's new balance to AstralCore's LeaderboardService, under that id, from the already-read post-mutation snapshot (no extra database read):
Two cases are skipped before the leaderboard is ever touched:
Town accounts — the leaderboard update short-circuits on any account whose
EconomyAccountTypeis notPLAYER, since a player leaderboard has no meaningful town entry.Currencies with no
leaderboard-id(nullor blank) —astralysin the shipped file has none, so its balance changes never reach a leaderboard.
A leaderboard update failure is caught and logged as a warning; it never fails the underlying money mutation. The shipped default currency uses leaderboard-id: currency-default.
default-balance
default-balance is the balance minted into a brand-new account row the first time one is provisioned for that currency — never applied to an account that already has a row for it. It is used by:
ensureCurrencies(account)— called on player join/first-seen; creates a row for every configured currency the player doesn't already have, each at that currency'sdefault-balance.ensureCurrency(account, currencyId, type)— creates a single missing row (used, for example, when a town account is first provisioned for a specific currency) at that currency'sdefault-balance.
With the shipped file, a new player account starts at 1000 default coins and 0 astralys; a currency added later only affects accounts created (or first-provisioned) after the change — existing account rows are never rewritten to match a new default-balance.
Currencies as placeholders
EconomyCurrency implements AstralCore's ComplexPlaceholder under namespace currency, so anywhere a currency is registered into a placeholder container (every /pay, /balance, and /eco message; the %economy_currency_<id>% chain point — see Placeholders) it exposes its fields as sub-keys:
Sub-key | Resolves to |
|---|---|
| The currency's |
| The |
| The |
| The |
| The |
| The |
| The |
For example, %currency_symbol% inside a message resolves to the registered currency's symbol — this is how pay-untransferable and the *_SUCCESS/*_FAILURE admin-edit messages render A$ or $ without hardcoding it. See Placeholders for the full %economy_*% namespace this chains from.
Money precision
accounts.balance and transactions.delta are both DECIMAL(19,4) columns — every balance and every signed delta is exact to 4 decimal places, never a double at rest. TransactionService enforces this at the boundary between the plugin's double-based command/SPI surface and the ledger:
Deposit / withdraw — the requested magnitude is normalized with
BigDecimal.setScale(4, RoundingMode.DOWN). A magnitude that is null, zero, or negative — or a sub-tick amount so small it rounds down to zero at 4 decimal places — is rejected outright asINVALID_AMOUNTbefore any ledger call. No transaction is ever created for it, so it can never mint or destroy money by rounding.Set — the target balance is normalized the same way (
setScale(4, RoundingMode.DOWN), never rounding up, since that would mint money on a set); a negative or null target is likewise rejected asINVALID_AMOUNT.Transfer — the same magnitude validation as deposit/withdraw applies to the transferred amount before either leg is written.
This rounding is a property of every mutation, not of a specific currency — it applies identically to default, astralys, and any currency added later. See Overview for how the guarded transaction itself makes an overdraft structurally impossible.