An order board that can't be duped

How SaltyOrders escrows money and items, survives crashes mid-trade, and holds up at thousands of players.

On this page 4 sections
  1. One path in, one path out
  2. Two audits
  3. Surviving a crash mid-trade
  4. Holding up at scale

SaltyOrders is a player market. You post a buy order (“I’ll pay 40 each for 64 diamonds”) and your money goes into escrow, or you post a sell order and your items do. When a new order crosses a resting one, they match: best price first, oldest order first at the same price, with a maker/taker rule deciding which of the two prices the trade fills at. Anything a trade owes you, whether money, items or a refund, waits in a collection box until you pick it up.

That’s a lot of places for money and items to be at once, which makes it exactly the kind of system that gets duped. So it was built on the assumption that someone will race every path in it.

One path in, one path out

Every payout, refund and delivered item is written as a pending row in the same database transaction as the trade that caused it, and claiming one is a conditional update whose affected-row count is the gate. Two clicks, two servers, or a click racing a shutdown all try to claim the same row, and exactly one of them wins. There’s no read-then-write window to slip through.

Around that, the ordering is chosen so a failure always lands on the safe side:

  • Items leave your inventory first, on your own region thread, before any async work starts. If anything later fails, they’re handed back exactly once, decided by a compare-and-set between the normal delivery, a retired callback, and the shutdown flush.
  • Money is withdrawn first and compensated on failure. If the database connection dies mid-commit, the outcome is genuinely unknown, so the plugin refuses to refund rather than guess and risk paying twice.
  • Money is integer minor units, never floating point. The plugin refuses to run against an economy that would lose precision, and serialises each player’s economy calls across 64 lock stripes.
  • Buyers receive the actual stacks that were deposited, not fresh copies built from a template. Cloning from a template turned out to dupe a full shulker box.

Two audits

Once it worked, it got attacked, twice, and the audits found real holes:

  • Selling while disconnecting. An action landing in the same moment as a disconnect was a working dupe. Actions from disconnected players are now dropped.
  • Racing settles. Without both a row-count guard and a row lock on settlement, three simultaneous settles paid out three times.
  • Double-clicks. Confirm and submit now fire once per window, so a double-click makes one order.
  • An open container. Trading is refused while a real container is open, and anvil search text is capped to stop a lag exploit.
  • “Same item” means the same item. Exact matching covers name, lore, model, the contents of containers and bundles, and plugin data, so nobody can sell a renamed stone as a diamond.

Surviving a crash mid-trade

The nastiest bug wasn’t in the plugin’s code at all. The database commits a trade instantly, but the server only writes a player’s inventory to disk every few minutes. Crash in between and the two disagree: reproduced with a scripted bot and a hard kill, selling 64 diamonds and then crashing left 64 diamonds in the inventory and 64 in the order.

The fix is a crash-rollback journal. Before a trade takes items, it snapshots the player’s inventory and ender chest (about a third of a millisecond, around 3 KB) and stamps a sequence number into the player’s own data. On the next boot, restores are only armed if the previous run didn’t shut down cleanly, and snapshots are keyed per install, so two servers sharing one Postgres database can never restore each other’s players.

The obvious alternative, force-saving the player after every trade, cost 6.5 ms at the median and 73 ms at p99, on the player’s own thread. The journal replaced it.

Holding up at scale

A market is read far more than it’s written, so it was load-tested with thousands of simulated players and profiled with Windchill and JFR every round:

  • Read caches with a short TTL took board and market reads from about 10 ms to about 1 µs. Invalidating on every write had been measured first and never hit.
  • Menus render on their own pool. A cold page costs 2.6 ms, a warm one 11 µs.
  • Reads get their own executor and connection pool, so a burst of writes can’t starve the market view.
  • At 1,000 players, the median Postgres order create fell from 38.6 ms to 12.5 ms, and the sweep that delivers fill notices went from 1.9 s to 63 ms, going from 1,569 notices delivered in 90 seconds to about 12,300.
  • At 2,000 players Postgres serves 1,467 operations a second with a 7.4 ms median create, and at 5,000 the embedded H2 database serves 3,426 a second with reads under 15 ms at p99.

It’s backed by 99 tests that run against both H2 and Postgres, including concurrency, outage and economy-failure suites, and was checked in game with Java bots and Bedrock bots through Geyser on both Paper and Folia.