feat: add AfterCoin (AFC) private blockchain for Minecraft casino
Some checks failed
Deploy / deploy (push) Has been cancelled

Private Ethereum chain (Clique PoA, chain ID 8888) with ERC-20 token
(0 decimals, 1 AFC = 1 diamond) bridging casino balances on-chain so
players can view tokens in MetaMask.

- Geth v1.13.15 node with 5s block time, zero gas cost
- AfterCoin ERC-20 contract with owner-gated mint/burn/bridgeTransfer
- Bridge API (Express + ethers.js + SQLite) with register, deposit,
  withdraw, balance, and wallet endpoints
- Nonce queue for serial transaction safety
- Auto-deploys contract on first boot
- Updated mainframe Lua with diff-based on-chain sync (pcall fallback)
- Updated card generator Lua with wallet info display

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
consultoria-as
2026-02-26 00:48:22 +00:00
parent e65260c69b
commit 14279a878c
21 changed files with 2569 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --production
COPY contracts/ ./contracts/
COPY src/ ./src/
EXPOSE 3001
CMD ["node", "src/index.js"]

View File

@@ -0,0 +1,357 @@
{
"contractName": "AfterCoin",
"abi": [
{
"inputs": [],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"inputs": [
{
"internalType": "address",
"name": "spender",
"type": "address"
},
{
"internalType": "uint256",
"name": "required",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "available",
"type": "uint256"
}
],
"name": "InsufficientAllowance",
"type": "error"
},
{
"inputs": [
{
"internalType": "address",
"name": "account",
"type": "address"
},
{
"internalType": "uint256",
"name": "required",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "available",
"type": "uint256"
}
],
"name": "InsufficientBalance",
"type": "error"
},
{
"inputs": [],
"name": "NotOwner",
"type": "error"
},
{
"inputs": [],
"name": "ZeroAddress",
"type": "error"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "owner",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "spender",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "value",
"type": "uint256"
}
],
"name": "Approval",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "from",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "to",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "value",
"type": "uint256"
}
],
"name": "Transfer",
"type": "event"
},
{
"inputs": [
{
"internalType": "address",
"name": "tokenOwner",
"type": "address"
},
{
"internalType": "address",
"name": "spender",
"type": "address"
}
],
"name": "allowance",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "spender",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "approve",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "account",
"type": "address"
}
],
"name": "balanceOf",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "from",
"type": "address"
},
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "bridgeTransfer",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "from",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "burnFrom",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "decimals",
"outputs": [
{
"internalType": "uint8",
"name": "",
"type": "uint8"
}
],
"stateMutability": "pure",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "mint",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "name",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string"
}
],
"stateMutability": "pure",
"type": "function"
},
{
"inputs": [],
"name": "owner",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "symbol",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string"
}
],
"stateMutability": "pure",
"type": "function"
},
{
"inputs": [],
"name": "totalSupply",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "transfer",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "from",
"type": "address"
},
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "transferFrom",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "nonpayable",
"type": "function"
}
],
"bytecode": "0x6080604052348015600f57600080fd5b50600380546001600160a01b031916331790556108ac806100316000396000f3fe608060405234801561001057600080fd5b50600436106100cf5760003560e01c8063671afbce1161008c5780638da5cb5b116100665780638da5cb5b146101c657806395d89b41146101f1578063a9059cbb14610210578063dd62ed3e1461022357600080fd5b8063671afbce1461017757806370a082311461018a57806379cc6790146101b357600080fd5b806306fdde03146100d4578063095ea7b31461010b57806318160ddd1461012e57806323b872dd14610140578063313ce5671461015357806340c10f1914610162575b600080fd5b60408051808201909152600981526820b33a32b921b7b4b760b91b60208201525b6040516101029190610714565b60405180910390f35b61011e61011936600461077e565b61025c565b6040519015158152602001610102565b6000545b604051908152602001610102565b61011e61014e3660046107a8565b610273565b60405160008152602001610102565b61017561017036600461077e565b6102f9565b005b6101756101853660046107a8565b6103d3565b6101326101983660046107e5565b6001600160a01b031660009081526001602052604090205490565b6101756101c136600461077e565b61040e565b6003546101d9906001600160a01b031681565b6040516001600160a01b039091168152602001610102565b60408051808201909152600381526241464360e81b60208201526100f5565b61011e61021e36600461077e565b61052e565b610132610231366004610807565b6001600160a01b03918216600090815260026020908152604080832093909416825291909152205490565b600061026933848461053b565b5060015b92915050565b6001600160a01b038316600090815260026020908152604080832033845290915281205460001981146102e357828110156102d657604051630c95cf2760e11b815233600482015260248101849052604481018290526064015b60405180910390fd5b6102e3853385840361053b565b6102ee8585856105e2565b506001949350505050565b6003546001600160a01b03163314610324576040516330cd747160e01b815260040160405180910390fd5b6001600160a01b03821661034b5760405163d92e233d60e01b815260040160405180910390fd5b8060008082825461035c9190610850565b90915550506001600160a01b03821660009081526001602052604081208054839290610389908490610850565b90915550506040518181526001600160a01b038316906000907fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9060200160405180910390a35050565b6003546001600160a01b031633146103fe576040516330cd747160e01b815260040160405180910390fd5b6104098383836105e2565b505050565b6003546001600160a01b03163314610439576040516330cd747160e01b815260040160405180910390fd5b6001600160a01b0382166104605760405163d92e233d60e01b815260040160405180910390fd5b6001600160a01b038216600090815260016020526040902054818110156104b35760405163db42144d60e01b81526001600160a01b038416600482015260248101839052604481018290526064016102cd565b6001600160a01b03831660009081526001602052604081208383039055805483919081906104e2908490610863565b90915550506040518281526000906001600160a01b038516907fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef906020015b60405180910390a3505050565b60006102693384846105e2565b6001600160a01b0383166105625760405163d92e233d60e01b815260040160405180910390fd5b6001600160a01b0382166105895760405163d92e233d60e01b815260040160405180910390fd5b6001600160a01b0383811660008181526002602090815260408083209487168084529482529182902085905590518481527f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b9259101610521565b6001600160a01b0383166106095760405163d92e233d60e01b815260040160405180910390fd5b6001600160a01b0382166106305760405163d92e233d60e01b815260040160405180910390fd5b6001600160a01b038316600090815260016020526040902054818110156106835760405163db42144d60e01b81526001600160a01b038516600482015260248101839052604481018290526064016102cd565b6001600160a01b038085166000908152600160205260408082208585039055918516815290812080548492906106ba908490610850565b92505081905550826001600160a01b0316846001600160a01b03167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef8460405161070691815260200190565b60405180910390a350505050565b602081526000825180602084015260005b818110156107425760208186018101516040868401015201610725565b506000604082850101526040601f19601f83011684010191505092915050565b80356001600160a01b038116811461077957600080fd5b919050565b6000806040838503121561079157600080fd5b61079a83610762565b946020939093013593505050565b6000806000606084860312156107bd57600080fd5b6107c684610762565b92506107d460208501610762565b929592945050506040919091013590565b6000602082840312156107f757600080fd5b61080082610762565b9392505050565b6000806040838503121561081a57600080fd5b61082383610762565b915061083160208401610762565b90509250929050565b634e487b7160e01b600052601160045260246000fd5b8082018082111561026d5761026d61083a565b8181038181111561026d5761026d61083a56fea2646970667358221220385d4e7aa6f29433bdf52f6b52498c4501151f35e2ecf304acc5bb0007e5327264736f6c63430008220033"
}

1358
services/afc-bridge/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
{
"name": "afc-bridge",
"version": "1.0.0",
"description": "AfterCoin bridge API - syncs Minecraft casino balances to private Ethereum chain",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"deploy": "node src/deploy.js"
},
"dependencies": {
"express": "^4.21.0",
"ethers": "^6.13.0",
"better-sqlite3": "^11.0.0"
}
}

View File

@@ -0,0 +1,23 @@
if (!process.env.ADMIN_PRIVATE_KEY) {
throw new Error("ADMIN_PRIVATE_KEY environment variable is required");
}
if (!process.env.BRIDGE_SECRET) {
throw new Error("BRIDGE_SECRET environment variable is required");
}
// Normalize private key to have 0x prefix for ethers.js
const rawKey = process.env.ADMIN_PRIVATE_KEY;
const normalizedKey = rawKey.startsWith("0x") ? rawKey : "0x" + rawKey;
const config = {
GETH_RPC_URL: process.env.GETH_RPC_URL || "http://geth:8545",
ADMIN_PRIVATE_KEY: normalizedKey,
AFC_CONTRACT_ADDRESS: process.env.AFC_CONTRACT_ADDRESS || "",
BRIDGE_SECRET: process.env.BRIDGE_SECRET,
PORT: parseInt(process.env.PORT) || 3001,
DB_PATH: process.env.DB_PATH || "/data/bridge.db",
GAS_FUND_AMOUNT: process.env.GAS_FUND_AMOUNT || "0.01",
};
module.exports = config;

View File

@@ -0,0 +1,64 @@
const Database = require("better-sqlite3");
const config = require("./config");
const db = new Database(config.DB_PATH);
// Enable WAL mode for better concurrent read performance
db.pragma("journal_mode = WAL");
// Create tables if they don't exist
db.exec(`
CREATE TABLE IF NOT EXISTS wallets (
disk_id TEXT PRIMARY KEY,
address TEXT NOT NULL,
private_key TEXT NOT NULL,
name TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS contract_state (
key TEXT PRIMARY KEY,
value TEXT
);
`);
function getWallet(diskId) {
const stmt = db.prepare("SELECT * FROM wallets WHERE disk_id = ?");
return stmt.get(diskId) || null;
}
function createWallet(diskId, address, privateKey, name) {
const stmt = db.prepare(
"INSERT INTO wallets (disk_id, address, private_key, name) VALUES (?, ?, ?, ?)"
);
return stmt.run(diskId, address, privateKey, name);
}
function getContractAddress() {
const stmt = db.prepare(
"SELECT value FROM contract_state WHERE key = 'contract_address'"
);
const row = stmt.get();
return row ? row.value : null;
}
function setContractAddress(address) {
const stmt = db.prepare(
"INSERT OR REPLACE INTO contract_state (key, value) VALUES ('contract_address', ?)"
);
return stmt.run(address);
}
function getAllWallets() {
const stmt = db.prepare("SELECT * FROM wallets");
return stmt.all();
}
module.exports = {
db,
getWallet,
createWallet,
getContractAddress,
setContractAddress,
getAllWallets,
};

View File

@@ -0,0 +1,33 @@
const config = require("./config");
const db = require("./db");
const { waitForGeth, deployContract } = require("./ethereum");
async function main() {
console.log("=== AfterCoin Contract Deployment ===");
// Wait for the Geth node to be reachable
await waitForGeth();
// Check if already deployed
const existing = db.getContractAddress();
if (existing) {
console.log("Contract already deployed at:", existing);
console.log(
"To redeploy, clear the contract_state table in the database."
);
return existing;
}
// Deploy the contract
const address = await deployContract();
console.log("=== Deployment complete ===");
console.log("Contract address:", address);
return address;
}
main()
.then(() => process.exit(0))
.catch((err) => {
console.error("Deployment failed:", err);
process.exit(1);
});

View File

@@ -0,0 +1,64 @@
const { ethers } = require("ethers");
const path = require("path");
const config = require("./config");
const db = require("./db");
const abiPath = path.join(__dirname, "..", "contracts", "AfterCoin.json");
const artifact = require(abiPath);
const provider = new ethers.JsonRpcProvider(config.GETH_RPC_URL);
const adminWallet = new ethers.Wallet(config.ADMIN_PRIVATE_KEY, provider);
function getContract() {
const address = db.getContractAddress() || config.AFC_CONTRACT_ADDRESS;
if (!address) {
throw new Error(
"AfterCoin contract not deployed yet. Run 'npm run deploy' first."
);
}
return new ethers.Contract(address, artifact.abi, adminWallet);
}
async function deployContract() {
console.log("Deploying AfterCoin contract...");
console.log("Admin address:", adminWallet.address);
const factory = new ethers.ContractFactory(
artifact.abi,
artifact.bytecode,
adminWallet
);
const contract = await factory.deploy();
await contract.waitForDeployment();
const address = await contract.getAddress();
db.setContractAddress(address);
console.log("AfterCoin deployed at:", address);
return address;
}
async function waitForGeth(retryInterval = 5000, maxRetries = 60) {
for (let i = 0; i < maxRetries; i++) {
try {
const blockNumber = await provider.getBlockNumber();
console.log(`Geth is ready (block #${blockNumber})`);
return;
} catch (err) {
console.log(
`Waiting for Geth... attempt ${i + 1}/${maxRetries} (${err.message})`
);
await new Promise((resolve) => setTimeout(resolve, retryInterval));
}
}
throw new Error(`Geth did not respond after ${maxRetries} retries`);
}
module.exports = {
provider,
adminWallet,
getContract,
deployContract,
waitForGeth,
};

View File

@@ -0,0 +1,79 @@
const express = require("express");
const config = require("./config");
const db = require("./db");
const { waitForGeth, deployContract, getContract } = require("./ethereum");
const registerRouter = require("./routes/register");
const depositRouter = require("./routes/deposit");
const withdrawRouter = require("./routes/withdraw");
const balanceRouter = require("./routes/balance");
const walletRouter = require("./routes/wallet");
const app = express();
// Parse JSON request bodies
app.use(express.json());
// Auth middleware — only applies to state-changing (POST) routes
app.use((req, res, next) => {
if (req.method !== "POST") {
return next();
}
const secret = req.headers["x-bridge-secret"];
if (secret !== config.BRIDGE_SECRET) {
return res
.status(401)
.json({ success: false, error: "Unauthorized: invalid bridge secret" });
}
next();
});
// Mount routes
app.use(registerRouter);
app.use(depositRouter);
app.use(withdrawRouter);
app.use(balanceRouter);
app.use(walletRouter);
// Global error handler
app.use((err, req, res, next) => {
console.error("Unhandled error:", err);
res.status(500).json({
success: false,
error: err.message || "Internal server error",
});
});
// Startup sequence
async function start() {
// 1. Wait for Geth node to be reachable
await waitForGeth();
// 2. Deploy contract if not already deployed
const existing = db.getContractAddress();
if (!existing) {
console.log("No contract found in database, deploying...");
await deployContract();
} else {
console.log("Contract already deployed at:", existing);
}
// Verify the contract is accessible
const contract = getContract();
const contractAddress = await contract.getAddress();
// 3. Start listening
app.listen(config.PORT, () => {
console.log("=== AFC Bridge API ===");
console.log(`Listening on port ${config.PORT}`);
console.log(`AfterCoin contract: ${contractAddress}`);
console.log(`Geth RPC: ${config.GETH_RPC_URL}`);
});
}
start().catch((err) => {
console.error("Failed to start AFC Bridge:", err);
process.exit(1);
});

View File

@@ -0,0 +1,31 @@
const { Router } = require("express");
const db = require("../db");
const { getContract } = require("../ethereum");
const router = Router();
router.get("/api/balance/:diskId", async (req, res, next) => {
try {
const { diskId } = req.params;
const wallet = db.getWallet(diskId);
if (!wallet) {
return res
.status(404)
.json({ success: false, error: "Wallet not found for this diskId" });
}
const contract = getContract();
const balance = await contract.balanceOf(wallet.address);
res.json({
success: true,
diskId,
balance: Number(balance),
});
} catch (err) {
next(err);
}
});
module.exports = router;

View File

@@ -0,0 +1,54 @@
const { Router } = require("express");
const db = require("../db");
const { getContract } = require("../ethereum");
const txQueue = require("../txQueue");
const router = Router();
router.post("/api/deposit", async (req, res, next) => {
try {
const { diskId, amount } = req.body;
if (!diskId || amount === undefined) {
return res
.status(400)
.json({ success: false, error: "diskId and amount are required" });
}
if (!Number.isInteger(amount) || amount <= 0) {
return res
.status(400)
.json({ success: false, error: "amount must be a positive integer" });
}
const wallet = db.getWallet(diskId);
if (!wallet) {
return res
.status(404)
.json({ success: false, error: "Wallet not found for this diskId" });
}
const contract = getContract();
// Mint tokens to the player's wallet via the transaction queue
const receipt = await txQueue.enqueue(async () => {
const tx = await contract.mint(wallet.address, amount);
return tx.wait();
});
// Read the new on-chain balance
const balance = await contract.balanceOf(wallet.address);
res.json({
success: true,
diskId,
amount,
balance: Number(balance),
txHash: receipt.hash,
});
} catch (err) {
next(err);
}
});
module.exports = router;

View File

@@ -0,0 +1,60 @@
const { Router } = require("express");
const { ethers } = require("ethers");
const config = require("../config");
const db = require("../db");
const { adminWallet, provider } = require("../ethereum");
const txQueue = require("../txQueue");
const router = Router();
router.post("/api/register", async (req, res, next) => {
try {
const { diskId, name } = req.body;
if (!diskId || !name) {
return res
.status(400)
.json({ success: false, error: "diskId and name are required" });
}
// Check if wallet already exists
const existing = db.getWallet(diskId);
if (existing) {
return res.json({
success: true,
diskId: existing.disk_id,
address: existing.address,
name: existing.name,
});
}
// Create a new random wallet
const wallet = ethers.Wallet.createRandom();
// Save to database
db.createWallet(diskId, wallet.address, wallet.privateKey, name);
// Fund the new wallet with gas ETH from the admin wallet
await txQueue.enqueue(async () => {
const tx = await adminWallet.sendTransaction({
to: wallet.address,
value: ethers.parseEther(config.GAS_FUND_AMOUNT),
});
await tx.wait();
console.log(
`Funded ${wallet.address} with ${config.GAS_FUND_AMOUNT} ETH (tx: ${tx.hash})`
);
});
res.json({
success: true,
diskId,
address: wallet.address,
name,
});
} catch (err) {
next(err);
}
});
module.exports = router;

View File

@@ -0,0 +1,29 @@
const { Router } = require("express");
const db = require("../db");
const router = Router();
router.get("/api/wallet/:diskId", async (req, res, next) => {
try {
const { diskId } = req.params;
const wallet = db.getWallet(diskId);
if (!wallet) {
return res
.status(404)
.json({ success: false, error: "Wallet not found for this diskId" });
}
res.json({
success: true,
diskId,
address: wallet.address,
privateKey: wallet.private_key,
name: wallet.name,
});
} catch (err) {
next(err);
}
});
module.exports = router;

View File

@@ -0,0 +1,63 @@
const { Router } = require("express");
const db = require("../db");
const { getContract } = require("../ethereum");
const txQueue = require("../txQueue");
const router = Router();
router.post("/api/withdraw", async (req, res, next) => {
try {
const { diskId, amount } = req.body;
if (!diskId || amount === undefined) {
return res
.status(400)
.json({ success: false, error: "diskId and amount are required" });
}
if (!Number.isInteger(amount) || amount <= 0) {
return res
.status(400)
.json({ success: false, error: "amount must be a positive integer" });
}
const wallet = db.getWallet(diskId);
if (!wallet) {
return res
.status(404)
.json({ success: false, error: "Wallet not found for this diskId" });
}
const contract = getContract();
// Check on-chain balance before burning
const balance = await contract.balanceOf(wallet.address);
if (Number(balance) < amount) {
return res.status(400).json({
success: false,
error: `Insufficient balance: has ${Number(balance)}, requested ${amount}`,
});
}
// Burn tokens from the player's wallet via the transaction queue
const receipt = await txQueue.enqueue(async () => {
const tx = await contract.burnFrom(wallet.address, amount);
return tx.wait();
});
// Read updated balance
const newBalance = await contract.balanceOf(wallet.address);
res.json({
success: true,
diskId,
amount,
balance: Number(newBalance),
txHash: receipt.hash,
});
} catch (err) {
next(err);
}
});
module.exports = router;

View File

@@ -0,0 +1,9 @@
let queue = Promise.resolve();
function enqueue(fn) {
const task = queue.then(() => fn());
queue = task.catch(() => {}); // don't let rejections break the chain
return task;
}
module.exports = { enqueue };