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

12
blockchain/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM ethereum/client-go:v1.13.15
COPY genesis.json /app/genesis.json
COPY init-geth.sh /app/init-geth.sh
RUN chmod +x /app/init-geth.sh
EXPOSE 8545 8546
WORKDIR /app
ENTRYPOINT ["/app/init-geth.sh"]

View File

@@ -0,0 +1,183 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/**
* @title AfterCoin (AFC)
* @notice ERC-20 token for the Afterlife Project game preservation platform.
* 1 AFC = 1 diamond. Zero decimals — integer-only balances.
* @dev Self-contained implementation (no OpenZeppelin). Owner-gated mint,
* burn-from, and bridge-transfer helpers for the off-chain bridge service.
*/
contract AfterCoin {
// ──────────────────────────── ERC-20 metadata ────────────────────────────
string private constant _name = "AfterCoin";
string private constant _symbol = "AFC";
uint8 private constant _decimals = 0; // 1 token = 1 diamond
// ──────────────────────────── State ──────────────────────────────────────
uint256 private _totalSupply;
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
address public owner;
// ──────────────────────────── Events (ERC-20) ────────────────────────────
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
// ──────────────────────────── Errors ─────────────────────────────────────
error NotOwner();
error ZeroAddress();
error InsufficientBalance(address account, uint256 required, uint256 available);
error InsufficientAllowance(address spender, uint256 required, uint256 available);
// ──────────────────────────── Modifier ───────────────────────────────────
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
// ──────────────────────────── Constructor ────────────────────────────────
constructor() {
owner = msg.sender;
// Initial supply is 0 — tokens are minted on demand by the bridge.
}
// ──────────────────────────── ERC-20 view functions ──────────────────────
function name() external pure returns (string memory) {
return _name;
}
function symbol() external pure returns (string memory) {
return _symbol;
}
function decimals() external pure returns (uint8) {
return _decimals;
}
function totalSupply() external view returns (uint256) {
return _totalSupply;
}
function balanceOf(address account) external view returns (uint256) {
return _balances[account];
}
function allowance(address tokenOwner, address spender) external view returns (uint256) {
return _allowances[tokenOwner][spender];
}
// ──────────────────────────── ERC-20 mutative functions ──────────────────
function transfer(address to, uint256 amount) external returns (bool) {
_transfer(msg.sender, to, amount);
return true;
}
function approve(address spender, uint256 amount) external returns (bool) {
_approve(msg.sender, spender, amount);
return true;
}
function transferFrom(
address from,
address to,
uint256 amount
) external returns (bool) {
uint256 currentAllowance = _allowances[from][msg.sender];
if (currentAllowance != type(uint256).max) {
if (currentAllowance < amount) {
revert InsufficientAllowance(msg.sender, amount, currentAllowance);
}
unchecked {
_approve(from, msg.sender, currentAllowance - amount);
}
}
_transfer(from, to, amount);
return true;
}
// ──────────────────────────── Owner-only functions ───────────────────────
/**
* @notice Mint new tokens to `to`. Only callable by the contract owner.
* @param to Recipient address.
* @param amount Number of tokens to create.
*/
function mint(address to, uint256 amount) external onlyOwner {
if (to == address(0)) revert ZeroAddress();
_totalSupply += amount;
_balances[to] += amount;
emit Transfer(address(0), to, amount);
}
/**
* @notice Burn tokens from `from`. Only callable by the contract owner.
* Does NOT require an allowance — the owner is the bridge operator.
* @param from Address whose tokens are burned.
* @param amount Number of tokens to destroy.
*/
function burnFrom(address from, uint256 amount) external onlyOwner {
if (from == address(0)) revert ZeroAddress();
uint256 bal = _balances[from];
if (bal < amount) {
revert InsufficientBalance(from, amount, bal);
}
unchecked {
_balances[from] = bal - amount;
}
_totalSupply -= amount;
emit Transfer(from, address(0), amount);
}
/**
* @notice Transfer tokens between two addresses on behalf of the bridge.
* Only callable by the contract owner.
* @param from Source address.
* @param to Destination address.
* @param amount Number of tokens to move.
*/
function bridgeTransfer(
address from,
address to,
uint256 amount
) external onlyOwner {
_transfer(from, to, amount);
}
// ──────────────────────────── Internal helpers ───────────────────────────
function _transfer(address from, address to, uint256 amount) internal {
if (from == address(0)) revert ZeroAddress();
if (to == address(0)) revert ZeroAddress();
uint256 fromBal = _balances[from];
if (fromBal < amount) {
revert InsufficientBalance(from, amount, fromBal);
}
unchecked {
_balances[from] = fromBal - amount;
}
_balances[to] += amount;
emit Transfer(from, to, amount);
}
function _approve(address tokenOwner, address spender, uint256 amount) internal {
if (tokenOwner == address(0)) revert ZeroAddress();
if (spender == address(0)) revert ZeroAddress();
_allowances[tokenOwner][spender] = amount;
emit Approval(tokenOwner, spender, amount);
}
}

27
blockchain/genesis.json Normal file
View File

@@ -0,0 +1,27 @@
{
"config": {
"chainId": 8888,
"homesteadBlock": 0,
"eip150Block": 0,
"eip155Block": 0,
"eip158Block": 0,
"byzantiumBlock": 0,
"constantinopleBlock": 0,
"petersburgBlock": 0,
"istanbulBlock": 0,
"berlinBlock": 0,
"londonBlock": 0,
"clique": {
"period": 5,
"epoch": 0
}
},
"difficulty": "0x1",
"gasLimit": "0x1C9C380",
"extradata": "0x0000000000000000000000000000000000000000000000000000000000000000751c6F0Efd9B97A004969cfF9ACfA32230bdC4c40000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"alloc": {
"0x751c6F0Efd9B97A004969cfF9ACfA32230bdC4c4": {
"balance": "0xffffffffffffffff"
}
}
}

50
blockchain/init-geth.sh Executable file
View File

@@ -0,0 +1,50 @@
#!/bin/sh
set -e
GENESIS_FILE="/app/genesis.json"
DATADIR="/data"
# Initialize geth datadir if not already done
if [ ! -d "$DATADIR/geth/chaindata" ]; then
echo "Initializing geth datadir with genesis block..."
geth init --datadir "$DATADIR" "$GENESIS_FILE"
fi
# Import admin private key if provided and no accounts exist yet
if [ -n "$ADMIN_PRIVATE_KEY" ]; then
EXISTING_ACCOUNTS=$(geth account list --datadir "$DATADIR" 2>/dev/null || true)
if [ -z "$EXISTING_ACCOUNTS" ]; then
echo "Importing admin private key..."
TMPKEY=$(mktemp)
echo "$ADMIN_PRIVATE_KEY" > "$TMPKEY"
geth account import --datadir "$DATADIR" --password /dev/null --lightkdf "$TMPKEY"
rm -f "$TMPKEY"
else
echo "Account(s) already exist, skipping import."
fi
fi
echo "Starting geth node..."
exec geth \
--datadir "$DATADIR" \
--networkid 8888 \
--http \
--http.addr 0.0.0.0 \
--http.port 8545 \
--http.api eth,net,web3,personal,txpool \
--http.corsdomain "*" \
--http.vhosts "*" \
--ws \
--ws.addr 0.0.0.0 \
--ws.port 8546 \
--ws.api eth,net,web3 \
--ws.origins "*" \
--mine \
--miner.etherbase "$ADMIN_ADDRESS" \
--unlock "$ADMIN_ADDRESS" \
--password /dev/null \
--allow-insecure-unlock \
--nodiscover \
--maxpeers 0 \
--syncmode full \
--gcmode archive

View File

@@ -24,3 +24,9 @@ PUBLIC_HOST=play.yourdomain.com
# Cloudflare API Token (create at https://dash.cloudflare.com/profile/api-tokens) # Cloudflare API Token (create at https://dash.cloudflare.com/profile/api-tokens)
# Permissions needed: Zone > DNS > Edit # Permissions needed: Zone > DNS > Edit
CF_API_TOKEN=your_cloudflare_api_token CF_API_TOKEN=your_cloudflare_api_token
# AfterCoin Blockchain (private Ethereum chain for casino tokens)
# Generate with: node -e "const {ethers}=require('ethers');const w=ethers.Wallet.createRandom();console.log(w.address,w.privateKey)"
AFC_ADMIN_ADDRESS=0xYOUR_ADMIN_ADDRESS
AFC_ADMIN_PRIVATE_KEY=your_private_key_without_0x_prefix
AFC_BRIDGE_SECRET=change_me_in_production

View File

@@ -116,8 +116,47 @@ services:
limits: limits:
memory: 8G memory: 8G
geth:
build:
context: ../blockchain
dockerfile: Dockerfile
restart: unless-stopped
environment:
ADMIN_PRIVATE_KEY: ${AFC_ADMIN_PRIVATE_KEY}
ADMIN_ADDRESS: ${AFC_ADMIN_ADDRESS}
ports:
- "8545:8545"
- "8546:8546"
volumes:
- geth_data:/data
deploy:
resources:
limits:
memory: 1G
afc-bridge:
build:
context: ../services/afc-bridge
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
- geth
environment:
GETH_RPC_URL: http://geth:8545
ADMIN_PRIVATE_KEY: ${AFC_ADMIN_PRIVATE_KEY}
BRIDGE_SECRET: ${AFC_BRIDGE_SECRET}
PORT: 3001
DB_PATH: /data/bridge.db
GAS_FUND_AMOUNT: "0.01"
ports:
- "3001:3001"
volumes:
- afc_bridge_data:/data
volumes: volumes:
postgres_data: postgres_data:
minio_data: minio_data:
openfusion_data: openfusion_data:
minecraft_ftb_data: minecraft_ftb_data:
geth_data:
afc_bridge_data:

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 };