EconomyPol is a Paper economy plugin for EarthPol built around a discrete, item-based commodity currency.
It is designed to let players hold and move real money items while still providing:
- VaultUnlocked v2 compatibility for modern plugins and Towny
- Legacy Vault compatibility for older plugins
- Database-backed custodial storage and shared accounts
- A managed offline ender-wallet snapshot for offline spending support
- Explicit Towny government bindings for town and nation bank accounts
- Audit logging, health checks, and a native API for EconomyPol-specific behavior
This README describes the plugin as it is currently implemented.
EconomyPol is already usable as a database-backed commodity economy core, but it is still early-stage.
Implemented today:
- Discrete denomination-based currency
- Player accounts and shared accounts
- Player registration rows with foreign-keyed player-owned economy data
- Join-time player identity refresh for
economy_players.usernameand existing player account names - Custodial balances and reservations
- VaultUnlocked v2 provider
- Legacy Vault provider
- Managed offline ender-wallet snapshots
- Persistent offline player notifications
- Folia-safe scheduler coordination for player-bound inventory work
- Towny government binding and lifecycle synchronization
- Split startup/runtime config with reloadable
config.yml - Database health checks
- Native
EconomyPolAPI
Not implemented yet:
- PlaceholderAPI support
- GUI banking/teller interfaces
- broader admin repair commands for malformed data
- Rich transaction history commands
EconomyPol separates money into three states.
This is real item money held by a player in top-level inventory slots.
By default, current implementation counts:
- player inventory contents
- offhand
- ender chest contents
This can be narrowed through wallet.include-live-player-inventory and wallet.include-live-ender-chest.
Current implementation does not count:
- nested containers inside inventories or ender chests
- shulkers or other container recursion
Money is defined by configured denominations, for example:
GOLD_NUGGET = 1GOLD_INGOT = 9GOLD_BLOCK = 81
All values are integer base units stored as long.
Custodial is database-backed stored value.
For players, custodial is primarily an overflow and storage mechanism:
- admins can explicitly deposit physical money into custodial with
/economypol deposit - online routed payouts can spill into custodial when no physical storage space remains
- offline credits can land in custodial if the offline ender-wallet is unavailable
- players must explicitly withdraw custodial funds as physical money to carry and use them
Important current rule:
- Player custodial money is not part of player spendable balance.
- Player
getBalance/has/ normalwithdrawpaths only use physical money or the frozen offline ender-wallet snapshot.
For shared accounts, custodial is the full source of truth:
- shared accounts are pure ledger balances
- shared accounts do not have physical inventory state
- bank/shared withdrawals and deposits use custodial directly
This is not a general offline inventory scan.
It is a managed snapshot of the player’s top-level ender chest money taken on quit and restored on next login.
Purpose:
- allow limited offline player spending/receiving
- avoid reading arbitrary offline NBT as a live source of truth
Rules:
- only top-level ender chest money is snapshotted
- nested containers are ignored
- on login, the snapshot is materialized back into the ender chest
- malformed stacks or delivery overflow are moved to custodial
There are two distinct withdrawal concepts:
This is the generic economy spend path used by Vault/VaultUnlocked and account withdrawals.
For players:
- online player: removes live physical money from inventory/offhand/ender chest
- offline player: debits the frozen offline ender-wallet snapshot
- custodial is not auto-spent
- if
routing.change-overflow-policyisCUSTODIAL, unplaceable returned change is credited to custodial instead of canceling the spend
For shared accounts:
- debits custodial balance
This is the explicit player action that converts custodial money into physical money items.
It:
- debits the player’s custodial balance
- routes the payout through the supplied routing targets
- returns any undeliverable remainder back into custodial
Admin users can still use this path directly with /economypol withdraw <amount>.
For normal players, /economypol withdraw now withdraws the maximum exact amount that can fit into the player inventory and leaves the rest in custodial. This is intentionally more restrictive than generic economy behavior so players are encouraged to physicalize and manage carried money.
When a player account is credited:
- if the player is online, money is materialized and routed through the configured order
- any remainder is credited to custodial
- if the player is offline and has a frozen offline ender-wallet snapshot, the snapshot is credited
- if the player is offline and has no usable snapshot, the amount is credited to custodial
/economypol deposit <amount|all> removes physical money from the player’s live sources and stores it in custodial.
This command is currently gated behind economypol.admin.
Shared accounts are ledger-only.
They support:
- direct custodial deposits
- direct custodial withdrawals
- owner/member relationships
- reservations
This is the correct model for towns, nations, server treasuries, and other non-player entities.
economy_balances is EconomyPol's per-account database balance table.
For every account_id, it stores:
available_balancereserved_balance
This table is the source of truth for all database-backed balance movement. Reservations, custodial credits, custodial withdrawals, and shared-account deposits or withdrawals all flow through it.
For non-player accounts, meaning shared accounts such as:
- towns
- nations
- server treasuries
- admin-created banks
economy_balances is the actual balance.
That means:
- the shared account's money lives entirely in the database
available_balanceis the spendable bank balancereserved_balanceis money temporarily locked for an in-flight operation- shared accounts do not have a physical-inventory money state
So when TNE shared accounts are migrated into EconomyPol, their balances are meant to land in economy_balances.
For player accounts, economy_balances means something different.
It is the player's custodial balance, not the player's normal carried money.
Player money is intentionally split across multiple states:
- live physical money in inventory/offhand/ender chest
- frozen offline ender-wallet snapshot value
- custodial database money in
economy_balances
Important current rule:
- player
spendablebalance does not include custodial
So a player can have money in economy_balances and still be unable to spend through normal Vault/player withdraw flows until they explicitly materialize that money back into physical form.
In practice, player custodial is used for cases like:
- explicit self-deposit of physical money into storage
- overflow when incoming money or returned change does not fully fit physically
- offline fallback credits when the managed offline wallet cannot be used
- temporary reservation during custodial-to-physical withdrawal
If you populate economy_balances for a shared account, you are setting that bank's real balance.
If you populate economy_balances for a player account, you are giving that player custodial stored value, not money directly in their inventory.
That distinction is intentional and is central to how EconomyPol separates non-player ledger balances from player-held physical money.
EconomyPol has one canonical routing order:
INVENTORYENDER_CHESTCUSTODIAL_ACCOUNT
Current default:
routing:
change-overflow-policy: CUSTODIALFor passive incoming money and returned change, players can suppress the early stages of that order with /economypol paymentdelivery:
defaultINVENTORY -> ENDER_CHEST -> CUSTODIAL_ACCOUNT
skip_inventoryENDER_CHEST -> CUSTODIAL_ACCOUNT
skip_inventory_and_enderchestCUSTODIAL_ACCOUNT
This only affects passive incoming delivery and returned change. It does not affect explicit /economypol withdraw, which remains inventory-only by design because the player is actively choosing to physicalize money.
Returned change from live-money spends also has a configurable overflow policy:
FAIL- if physical change cannot fully fit, the spend is canceled and the original physical money state is restored
- stricter and more physical
- can expose compatibility bugs in third-party plugins that pre-check balance and then ignore failed withdraws
CUSTODIAL- if physical change cannot fully fit, only the unplaceable remainder is credited to player custodial
- more compatible with Towny, shops, and other Vault consumers
- means some returned change may become custodial money until the player explicitly withdraws it as physical cash
Routing applies to:
- online player credits
- returned change from live-money spends
Explicit /economypol withdraw is intentionally separate and inventory-only.
Denominations are exact integer conversions of the base unit.
EconomyPol validates that the denomination ladder is divisible, for example:
1981
Valid:
1 -> 9 -> 81
Invalid:
1 -> 10 -> 81
Materialization is highest-first.
Example:
100base units with the default ladder becomes:1 GOLD_BLOCK2 GOLD_INGOT1 GOLD_NUGGET
Fractional money does not exist.
- all storage uses integers
- Vault/VaultUnlocked fractional digit metadata is hardcoded to
0 - external decimal requests are normalized through
NumericalConsistencyService - the default policy is
TRUNCATE, and operators can switch toREJECT
EconomyPol centralizes decimal-to-integer behavior in NumericalConsistencyService.
This service is responsible for:
- converting
doubleandBigDecimalAPI amounts into integer base units - converting integer balances back into
doubleorBigDecimal - formatting non-whole external values consistently
- applying the operator-configured decimal policy
Current supported policies:
REJECT- non-whole values fail with an error
- example:
5.5is rejected - this is the safest mode for a strict discrete item economy
TRUNCATE- non-whole values are truncated toward zero
- example:
5.9 -> 5,-5.9 -> -5
The default config is:
numeric:
decimal-handling: TRUNCATENotes:
TRUNCATEalways truncates toward zero- even when decimals are truncated at the API boundary, the stored economy remains integer-only
The balance shown by EconomyPol is intentionally split.
PlayerBalanceView includes:
- spendable
- custodial available
- custodial reserved
- live money
- frozen ender wallet
- locked state
Current spendable for players is:
live money + frozen ender wallet
It does not include custodial.
That means:
- a player can have money in custodial and still have
0spendable - they must withdraw custodial as physical money to use it in player-spend flows
This is deliberate and matches the current EarthPol design choice.
If managed ender wallet is enabled:
- top-level ender chest money is counted
- a snapshot row is written with state
FROZEN
If a frozen snapshot exists:
- offline spends draw from that snapshot
- offline credits add to that snapshot
If no usable snapshot exists:
- offline player spending fails
- offline credits fall back to custodial
The player is money-locked while sync runs.
During sync:
- snapshot state becomes
SYNCING - the current top-level ender chest contents are cloned
- a theoretical final chest layout is computed from that clone
- only the frozen snapshot value is materialized back into top-level ender chest slots
- existing top-level money items are cleared and replaced as part of that planned layout
- overflow or malformed leftovers are moved to custodial
- the snapshot row is deleted after success
If join sync fails after the snapshot was moved to SYNCING:
- the original top-level ender chest contents are restored
- the original
FROZENsnapshot row is restored - the restored frozen snapshot is safe while the player is still online because online player balance and spending ignore frozen snapshots
- that restored snapshot acts as a dormant recovery record until the player logs out again or a later sync succeeds
If the server had an unclean shutdown:
- stale syncing snapshots are marked
DISABLED_UNCLEAN - startup logs emit severe entries describing each quarantined snapshot row
- offline ender-wallet behavior is disabled until the player returns
EconomyPol has a dedicated NotificationService.
It sends formatted messages for:
- incoming money routed to custodial
- withdraw overflow retained in custodial
- ender-wallet overflow moved to custodial
- custodial balance reminders
- offline custodial credits
- change routed to custodial because not all physical change fit
- strict spend failures caused by not enough room to return change
Offline notifications are persisted in the database and delivered on next login.
Notification text is now backed by EarthPolLib TranslationService.
- bundled locale files live under
src/main/resources/translations - on first boot, EarthPolLib exports them into the plugin data folder and creates
translations.yml - NotificationService resolves translated notification titles and bodies through that translation layer before sending or replaying queued notifications
Current queue table:
economy_player_notifications
Preferred shortcuts:
/bal/baltop/claim/compress/economypol paymentdelivery <default|skip_inventory|skip_inventory_and_enderchest>/economypol help/economypol
Supported long forms and aliases:
/economypol balance/economypol bal/economypol balancetop/economypol baltop/economypol claim/economypol withdraw/economypol normalizewallet/economypol compress/ecopol ...
Notes:
/claimand/economypol withdraware the player-facing overflow claim flow- for normal players,
/claimand/economypol withdrawmean “withdraw the maximum exact amount that fits in inventory” /baland/economypol balanceshow the player-facing balance breakdown: spendable, inventory, ender chest, and overflow account/baltopand/economypol balancetopshow the cached leaderboard built from online live money plus frozen offline ender-wallet snapshots/compressand/economypol normalizewalletnormalize the current ender chest money layout/economypol paymentdeliverycontrols how passive incoming money and returned change are routed for that player/economypol helpand/economypolshow the stylized command help summary
/economypol deposit <amount|all>/economypol withdraw <amount>
/economypol admin balance <player>/economypol admin check <report>/economypol admin cleanup towny-orphans/economypol admin reload
Available database check reports:
accountsbalancessnapshotsunclean-snapshotsreservationsnotificationstowny-accountsstats
/economypol admin check prints a chat-friendly report and also writes the full report to healthcheck.log.
towny-accounts inspects the explicit economy_towny_governments binding table and Towny-managed shared accounts for:
- orphaned binding rows whose Towny government no longer exists
- binding/account UUID mismatches against Towny's current bank account UUID
- owner/name mismatches on the bound shared account row
- missing binding rows for current Towny governments
- legacy Towny-style shared-account rows left behind without a binding
/economypol admin cleanup towny-orphans removes only orphaned Towny binding rows and legacy unbound Towny-style shared-account rows. It does not attempt to rewrite UUID-mismatch rows or repair missing canonical rows automatically.
/economypol admin reload reloads only config.yml.
database.ymlandcurrency.ymlare startup-onlylogging.debugdoes apply immediately on reloadlogging.retention-policydoes apply immediately on reload
Current permissions in plugin.yml:
economypol.admin- admin commands, defaultop
economypol.admin also gates the player self-deposit command and explicit amount withdraws.
EconomyPol now uses three config files.
Startup-only. Restart required after changes.
host: 127.0.0.1
port: 3306
name: economypol
username: root
password: changeme
disable-plugin-on-failure: trueStartup-only. Restart required after changes.
singular-name: Gold Coin
plural-name: Gold Coins
denominations:
- material: GOLD_NUGGET
base-units: 1
- material: GOLD_INGOT
base-units: 9
- material: GOLD_BLOCK
base-units: 81Generated into the plugin data folder and maintained through EarthPolLib ReloadableConfigHandler.
- this is the only EconomyPol config file reloaded by
/economypol admin reload - missing keys are repopulated from code defaults
- malformed scalar/list values fall back to defaults and are logged during reload
Default runtime config:
numeric:
decimal-handling: TRUNCATE
routing:
change-overflow-policy: CUSTODIAL
wallet:
managed-ender-wallet-enabled: true
include-live-player-inventory: true
include-live-ender-chest: true
cache:
balancetop-ttl-seconds: 60
logging:
debug: false
retention-policy: MONTHLYEconomyPol is suitable for selective runtime config reloads, not full blanket reloadability.
Safe runtime reload targets in config.yml:
numeric.*routing.change-overflow-policywallet.*cache.*logging.debuglogging.retention-policy
Restart-only settings:
- everything in
database.yml - everything in
currency.yml
Why the limit exists:
- database settings define startup database wiring
- currency settings define denomination materialization and adapter metadata
So the intended model is:
- use
/economypol admin reloadfor runtime behavior changes - restart the server for database or currency changes
- enables the quit snapshot / offline wallet / join restore system
- includes live player inventory and offhand in spendable live money
- includes top-level ender chest contents in spendable live money
- controls how Vault and VaultUnlocked decimal inputs are converted into integer base units
REJECTfails on non-whole valuesTRUNCATEtruncates toward zero- current default is
TRUNCATE
- controls what happens when a live-money spend must make change and the physical change does not fully fit
FAIL- restores the original physical money state and cancels the transaction
- stricter and more physical
- may expose compatibility bugs in plugins that ignore failed withdraws after a successful balance check
CUSTODIAL- credits only the unplaceable part of the returned change to player custodial
- more compatible with Towny, shops, and other Vault consumers
- means some returned change becomes non-physical until explicitly withdrawn
- controls how long the cached
/economypol balancetopleaderboard stays fresh - when stale, the next request rebuilds the cache by scanning all online players plus frozen offline ender-wallet snapshots
- player custodial balances are not part of this leaderboard
- if a rebuild is already running, later requesters are queued and all receive the rebuilt snapshot when it completes
- applies immediately on
/economypol admin reload - toggles debug emission on the operations logger
- applies immediately on
/economypol admin reload - controls log cleanup for the operations, audit, and healthcheck loggers
- supported values:
WEEKLYMONTHLYNEVER
Current migrations:
V1__init.sql
Schema conventions:
- UUID columns use MariaDB native
UUID - time columns use
TIMESTAMP(3) - the schema is written for MariaDB, which matches EarthPolLib's current database support
Main tables:
economy_players- registered player identity rows keyed by
player_uuid - refreshed automatically when a player joins
usernamestores the latest known player name- rows seeded earlier from UUID-only integrations are corrected on the next real join
- stores each player's
incoming_payment_delivery_preference
- registered player identity rows keyed by
economy_accounts- player and shared accounts
- stores shared account and player account identity only
- existing player account names are refreshed from
Player#getName()on join when they differ
economy_towny_governments- explicit Towny government to bank-account bindings
government_uuidis the raw Towny town or nation UUID and the row primary keyaccount_idis the EconomyPol shared account bound to that governmentbank_account_uuidis the Towny bank UUID Towny actually uses when calling EconomyPol
economy_account_members- shared account member relationships
economy_balances- available and reserved balances
economy_ender_wallet_snapshots- managed offline ender-wallet state
economy_reservations- holds/escrow state
economy_ledger_entries- audit trail of balance changes
economy_player_notifications- queued offline player notifications
EconomyPol uses multiple logs:
- operations log
- audit log
- healthcheck log
General operational status and warnings.
Examples:
- startup/shutdown
- runtime warnings
- queued notification delivery info
Immutable-style action logging.
Examples:
- balance changes
- deposits/withdrawals
- reservation lifecycle
- ender-wallet freeze/debit/credit/sync
Written by admin database checks.
Every /economypol admin check <report> run records:
- the report name
- exact run time
- duration
- string form of the full report
The health-check service is intentionally read-only.
It never repairs rows automatically.
Current checks look for malformed or inconsistent rows in:
- accounts
- balances
- snapshots
- unclean snapshots
- reservations
- notifications
The stats report also summarizes:
- account counts
- custodial totals
- snapshot totals
- active reservations
- pending player notifications
- ledger entry count
Important:
- database totals do not include live physical money in player inventories
This is the primary modern integration surface.
EconomyPol registers:
net.milkbowl.vault2.economy.Economy
It supports:
- UUID-based accounts
- shared accounts
- integer-only money handling with configurable decimal coercion at the API boundary
- Towny-compatible shared-account access when Towny is running in modern mode
EconomyPol also registers:
net.milkbowl.vault.economy.Economy
This exists for older plugins that still depend on the original Vault API.
Legacy Vault now uses the same numeric policy as VaultUnlocked through NumericalConsistencyService, so decimal behavior is consistent across both provider surfaces.
At runtime the VaultUnlocked plugin is still discovered by Bukkit as Vault, so EconomyPol declares softdepend: [Vault].
EconomyPol also declares loadbefore: [Towny, Quickshop-Hikari] so the provider is registered before those plugins initialize.
EconomyPol exposes a native Bukkit service for direct integration with the plugin. If possible, external plugins should integrate directly with EconomyPol for the best experience, however VaultUnlocked and Vault will still remain supported.
Capabilities include:
- explicit player vs shared-account operations
- detailed player balance view and custodial access
- shared account management
- incoming payment delivery preference access
- reservation lifecycle
- managed ender-wallet access
- denomination helpers
- structured
MoneyOperationResult.failureReason()values for native integrations
EconomyPolAPI is still the single Bukkit service entrypoint, but it is now split internally into focused account, player, reservation, ender-wallet, and denomination sub-interfaces.
API clients are now caller-bound through EconomyPolAPI.resolve(plugin), which lets EconomyPol log which plugin is making each native API call.
Native API calls do not implicitly create missing player or shared-account rows outside the explicit registerPlayer(...), ensurePlayerAccount(...), and createSharedAccount(...) paths. Integrations should create or check accounts first and treat missing-account failures as real integration errors.
Convenience overloads also exist for OfflinePlayer and Player on the player-facing native API methods. UUID-based methods remain the canonical identity surface.
Example:
EconomyPolAPI api = EconomyPolAPI.resolve(this)
.orElseThrow(() -> new IllegalStateException("EconomyPolAPI not available"));
long spendable = api.getPlayerSpendableBalance(playerUuid);
long custodial = api.getCustodialAvailable(playerUuid);
PlayerBalanceView view = api.getPlayerBalanceView(playerUuid);
long townBalance = api.getSharedAccountBalance(townAccountUuid);
MoneyOperationResult result = api.withdrawFromPlayerAccount(playerUuid, 10L, "CUSTOM_MARKET_BUY");
if (!result.success() && result.failureReason() == MoneyOperationFailureReason.NOT_ENOUGH_ROOM_FOR_CHANGE) {
// Handle a strict physical-economy change-space failure explicitly.
}Current Towny compatibility is through VaultUnlocked/Vault service registration plus an explicit Towny binding layer in EconomyPol.
That means:
- Towny can use EconomyPol as an economy provider
- Towny towns and nations are synchronized into EconomyPol shared accounts
- EconomyPol stores explicit Towny government bindings with:
- raw government UUID
- current Towny bank account UUID
- government type
- cached government and bank names
- Towny create/rename/delete lifecycle events are synchronized into EconomyPol
- VaultUnlocked UUID/shared-account support is available
Towny has two relevant identities for a government-backed bank:
- the raw Towny government UUID for the
TownorNation - the Towny bank account UUID that Towny passes to the economy provider
The bank account UUID is the economy-facing identity. The bank account name changes when the town or nation is renamed, but the UUID normally stays stable unless Towny's own UUID policy or repair tooling changes it.
EconomyPol stores both explicitly in economy_towny_governments.
Important columns:
government_uuid- the real Towny object UUID
- primary key of the binding row
government_typeTOWNorNATION
account_id- the EconomyPol shared account bound to that government
bank_account_uuid- the UUID Towny currently uses when calling EconomyPol
government_name- cached Towny town or nation name
bank_account_name- cached Towny bank account name such as
town-BustunTown
- cached Towny bank account name such as
In the healthy state:
account_id == bank_account_uuid
EconomyPol stores both fields separately so it can detect mismatches instead of silently rewriting the binding.
When Towny enables:
- EconomyPol's
TownyBootstrapListenernotices thePluginEnableEvent TownyServiceactivates the Towny integration backend- EconomyPol synchronizes all current towns and nations
- each government is reconciled through
syncGovernment(...)
For each town or nation, sync:
- reads the raw government UUID, current Towny bank account UUID, government name, and bank account name
- ensures the matching EconomyPol shared account exists for the bank account UUID
- updates the shared account name to match Towny
- sets the shared account owner metadata to the bank account UUID
- upserts the
economy_towny_governmentsbinding row
Towny lifecycle events then keep that binding current:
- new town or nation -> refresh binding
- rename town or nation -> refresh cached names and shared-account name
- delete town or nation -> delete the shared account and cascade-delete the binding row
A bank-account UUID mismatch means the binding row says one EconomyPol account is bound to a Towny government, but Towny currently derives or uses a different bank UUID for that same government.
Common causes:
- Towny's NPC/account UUID version policy changed
- a Towny admin UUID repair/reset path changed the bank UUID
- stale legacy Towny-style shared-account rows exist from before explicit bindings were added
- manual database edits or old buggy rows
Current EconomyPol behavior is intentionally conservative:
syncGovernment(...)logs a severe mismatch and refuses to silently rebind the government to a different account/economypol admin check towny-accountsreports the mismatch/economypol admin cleanup towny-orphansremoves only true orphan rows and legacy unbound rows, not live UUID mismatches
This avoids accidentally pointing a Towny government at the wrong balance history.
What is not implemented yet:
- Towny server-account binding
- automatic repair of UUID-mismatch rows caused by historic legacy data or Towny UUID-policy changes
So the current state is:
- Towny town/nation lifecycle integration is implemented
- Towny server-account binding is still future work
- Java 21
- Paper
1.21.x - VaultUnlocked available at runtime for the standard EarthPol deployment
- MariaDB through the configured JDBC layer
- Towny
0.102.xoptional but supported
Build commands used during development:
mvn -DskipTests compile
mvn test
mvn -DskipTests packageThe current implementation intentionally favors explicit, understandable money flows over invisible convenience.
Important caveats:
- Player custodial money is not auto-spendable.
- Only top-level inventories are scanned for money.
- Offline player spending only works through the frozen ender-wallet snapshot.
- Notifications are queued for offline players, but there is not yet a full inbox/history UI.
- Health checks report problems but do not repair them.
- Towny bank UUID mismatches are reported and blocked from silent rebinding instead of being auto-fixed.