🖥️For Developers
NetworkEconomy exposes a stable, fully asynchronous API plus two Bukkit events. Everything in the com.ohalee.networkeconomy.api package is the public surface; anything outside it is internal and may change.
Getting the API
Depend on NetworkEconomy in your plugin.yml so load order is guaranteed:
depend: [NetworkEconomy] # or softdepend if the hook is optionalThen grab the instance from your own onEnable:
NetworkEconomyAPI economy = NetworkEconomyProvider.get();With softdepend, guard the call:
if (NetworkEconomyProvider.isAvailable()) {
NetworkEconomyAPI economy = NetworkEconomyProvider.get();
}The API is also registered with the Bukkit ServicesManager. get() throws IllegalStateException if NetworkEconomy is not loaded yet, and the provider is cleared when the plugin disables.
Threading model
Every querying and mutating method is asynchronous and returns a CompletableFuture. Futures complete on an internal worker thread, never on the main server thread — so any Bukkit call inside a callback must be scheduled back onto the main thread.
economy.getBalance(uuid).thenAccept(balance -> {
// off the main thread!
Bukkit.getScheduler().runTask(plugin, () -> {
player.sendMessage("You have " + balance);
});
});This is deliberate: all operations are backed by an authoritative SQL store using atomic conditional updates, which is exactly what makes concurrent writes from different network nodes safe.
Fast reads for UI
For scoreboards, tab lists and anything that needs a number now, use the cached read. It performs no I/O and returns empty if the player is not cached (normally meaning they are not on this server).
Currencies
Currency is immutable and equality is by id. Useful members:
getId()
Lowercase config key
getDisplayName() / getDisplayNamePlural()
Human-readable names
getSymbol() / isSymbolBefore()
Symbol and its position
getDecimals()
Fractional digits
getStartingBalance()
Balance for new accounts
isPayable()
Whether /pay accepts it
isDefault()
Whether this is the Vault-exposed currency
normalize(BigDecimal)
Round to this currency's precision (half-up)
format(BigDecimal)
Format with grouping and symbol, e.g. $1,250.00
Reading balances
Mutating balances
reason is stored on the transaction row (up to 128 characters) and shown in the history GUI. Use something identifiable — "shop:diamond_sword", "quest:reward:12" — so the audit log stays useful.
Never implement your own "check then withdraw": has(...) followed by withdraw(...) is a race, because another server can spend the money in between. withdraw already performs the sufficiency check and the debit in a single atomic statement — just call it and handle INSUFFICIENT_FUNDS.
transfer moves money inside one database transaction: either both sides apply or neither does. Use it instead of a withdraw-then-deposit pair.
EconomyResult
status()
The outcome — see below
isSuccess()
true when the operation was persisted
newBalance()
Balance after the operation, or the current balance on failure
message()
Optional detail, null on success
SUCCESS
Completed and persisted
INSUFFICIENT_FUNDS
Not enough money for a withdrawal or transfer
ACCOUNT_NOT_FOUND
The player has never joined the network
CANCELLED
A PreTransactionEvent handler vetoed it
ERROR
Unexpected failure (SQL, connectivity, …)
History and listings
getHistory returns newest first. getTopBalances returns highest first and covers the whole network. lookupUuid resolves a name case-insensitively from the accounts table, so it works for any player who has ever joined — no Mojang API call, no main-thread stall.
TransactionRecord is a record with: id, player, currencyId, type, amount (always positive), balanceAfter, counterparty (nullable, transfers only), reason, serverId and timestamp.
BalanceEntry is a record with uuid, name (nullable) and balance.
TransactionType
DEPOSIT
Credit added (Vault deposit, plugin reward, …)
WITHDRAW
Debit removed (Vault withdraw, shop purchase, …)
TRANSFER_OUT
Outgoing side of a player-to-player transfer
TRANSFER_IN
Incoming side of a player-to-player transfer
SET
Balance overwritten to an absolute value
RESET
Balance reset to the currency's starting amount
Events
Both events are asynchronous — they fire off the main thread. Handlers must be thread-safe and must not touch the Bukkit API directly.
PreTransactionEvent
Fired before a change is committed, and cancellable. Cancelling aborts the operation with EconomyResult.Status.CANCELLED.
Exposes getPlayer(), getCurrency(), getType(), getAmount() and getReason().
BalanceUpdateEvent
Fired after a change has been committed and cached — including changes that originated on another network node and arrived over Redis. Informational; it cannot be cancelled.
Exposes getPlayer(), getCurrency(), getOldBalance(), getNewBalance(), getType() and isRemote().
Using Vault instead
If you already target Vault, nothing changes — NetworkEconomy registers as the Vault Economy provider at the highest priority and your plugin becomes network-wide for free. Two caveats:
Vault exposes only the default currency.
Vault's API is synchronous, so the bridge blocks briefly (5-second timeout) on the underlying async operation to return an accurate response. Banks are not supported and return
NOT_IMPLEMENTED.
Prefer the native async API for anything latency-sensitive.
Database schema
Read-only integrations can query the tables directly. All three use the configured table-prefix (default ne_) in place of {prefix}.
{prefix}accounts
uuid
CHAR(36) (PK)
Player unique id
name
VARCHAR(16)
Last known name, indexed (idx_name)
first_seen
BIGINT
Epoch millis
last_seen
BIGINT
Epoch millis
{prefix}balances
uuid
CHAR(36)
Part of PK (uuid, currency)
currency
VARCHAR(32)
Currency id
balance
DECIMAL(20,4)
Exact fixed-point balance
version
BIGINT
Monotonic, bumped on every change
Indexed by idx_currency_balance (currency, balance), which is what makes /baltop cheap.
{prefix}transactions
Append-only audit log — rows are never updated or deleted.
id
BIGINT (PK)
Auto-increment
uuid
CHAR(36)
Affected account
currency
VARCHAR(32)
Currency id
type
VARCHAR(16)
TransactionType name
amount
DECIMAL(20,4)
Always positive
balance_after
DECIMAL(20,4)
Balance immediately after the change
counterparty
CHAR(36)
Other party for transfers, else NULL
reason
VARCHAR(128)
Caller-supplied reason
server_id
VARCHAR(64)
Origin node's server-id
created_at
BIGINT
Epoch millis
Indexed by idx_uuid_time (uuid, created_at), idx_currency and idx_created.
Never write to {prefix}balances from outside the plugin. Balance changes must go through the API so they stay atomic, get a version bump, produce an audit row, and broadcast to the other nodes. Direct writes bypass all four.
Redis message format
Broadcasts are compact pipe-delimited strings on the configured channel — no JSON dependency:
reason is intentionally omitted to keep broadcasts small. Receivers apply an update only if its version is newer than the cached one, which makes delivery idempotent and out-of-order-safe, and skip messages stamped with their own serverId.
Testing notes
The plugin's own test suite runs the real storage layer against a real MariaDB and the real sync layer against a real Redis — no mocks, because the properties under test (atomicity, lock ordering, pub/sub delivery) only exist in a real engine.
Both integration classes skip themselves cleanly when no server is reachable, so the build still succeeds without a database. Override the endpoints with -Dne.test.host=…, -Dne.test.port=…, -Dne.test.redis.host=….
The concurrency tests hammer a single row from many threads as a stand-in for many network nodes, and are written so that replacing the atomic statements with naive read-modify-write logic makes them fail — verified by temporarily doing exactly that, which let 20 concurrent withdrawals of 10 all succeed against a balance of 100.
Last updated