We shipped a fully on-chain Minecraft economy. Here's the full technical architecture.
Server live at 172.240.53.57:2050 |
mincraftdex.com
1/ Every player gets a deterministic custodial keypair on first verify. Generated server-side via @solana/web3.js Keypair.generate(), private key AES-256-CBC encrypted at rest with a server-side master key, stored per wallet_address in Postgres.
No two players share a signing key. Ever.
2/ The verify handshake:
Client → POST /api/verify/start { walletAddress }Server → generates 6-char code, writes to verifications tablePlayer → /verify CRAFT-XXXXXX in Minecraft chatPlugin → GET /api/verify/confirm/:code { username }Server → checks player is ONLINE via Minecraft query protocolServer → links wallet ↔ username, provisions custodial keypairClient polls → GET /api/verify/status/:wallet
The online-check is the anti-fraud layer. You cannot claim a username you're not actively playing on.
3/ SPL Token-2022 (not legacy SPL). CA: AdD5EMoGMDiGXcgepXQXneB4fEZuS1QvKqDrN876pump
6 decimals. All on-chain amounts divided by 1_000_000 for display. Transfer instructions built with createTransferCheckedInstruction from @solana/spl-token, using TOKEN_2022_PROGRAM_ID — not the old TOKEN_PROGRAM_ID. This distinction breaks 90% of naive Solana integrations.
4/ Two-layer balance system:
Layer 1 → game_balance (Postgres, instant, free)↑ deposits detected via RPC polling↓ purchases deducted hereLayer 2 → on-chain CRAFT balance (Solana, real assets)↑ player sends CRAFT to their custodial addr↓ withdrawal triggers signed TX from custodial keypair
Shop transactions NEVER touch the blockchain. They settle in Layer 1. This eliminates gas costs for every item purchase and makes the token supply inelastic to game activity.
5/ The RPC layer has 3-endpoint failover:
const RPCS = ["
api.mainnet-beta.solana.com","
rpc.ankr.com/solana","
solana.publicnode.com"];
Round-robin with exponential backoff. If one endpoint rate-limits or drops, the next one signs the TX. Uptime is not RPC-dependent.
6/ The Java plugin fires a signed HTTP POST to the backend on every /verify command:
HttpRequest request = HttpRequest.newBuilder().uri(URI.create(API_BASE "/api/verify/confirm/" code)).header("X-Plugin-Key", PLUGIN_API_KEY).header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString("{\"minecraftUsername\":\"" player.getName() "\"}")).build();
The X-Plugin-Key is a 48-char hex secret shared only between the plugin JAR and the API env. No key = no confirm. Can't spoof verification from outside.
7/ Soft-unlink architecture — this one's subtle:
Most devs DELETE the row on wallet disconnect. We don't. We tombstone it:
UPDATE linked_accountsSET minecraft_username = '__UNLINKED_' || extract(epoch from now())WHERE wallet_address = $1
On re-verify, we detect the prefix, restore the row in-place. Balance preserved. Custodial keypair preserved. Deposit history intact. Zero fund loss on reconnect.
8/ The deposit detection loop polls every 30s per active custodial address. It fetches the SPL Token-2022 account balance via getTokenAccountsByOwner, diffs against last known balance, and credits the delta to game_balance. No webhooks, no indexer dependency, no third-party infra.
9/ Anti-bubble mechanics:
-On-chain supply doesn't move when players buy items
-Withdrawal requires explicit user action (not automatic)
-No bonding curve, no AMM interaction on shop purchases
Token velocity stays low → less pump/dump surface area
Each player's custodial wallet is isolated → no single wallet is "the treasury"
10/ Full stack:
PaperMC 1.21.1 → Java plugin, event listeners, HTTP clientNode.js/Express → REST API, TX signing, RPC callsPostgreSQL/Drizzle → accounts, balances, purchases, depositsReact/Vite → frontend, wallet UIPhantom → browser extension wallet adapterSolana web3.js → keypair management, TX constructionSPL Token-2022 → CRAFT token standard