feat: add AfterCoin (AFC) private blockchain for Minecraft casino
Some checks failed
Deploy / deploy (push) Has been cancelled
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:
13
services/afc-bridge/Dockerfile
Normal file
13
services/afc-bridge/Dockerfile
Normal 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"]
|
||||
357
services/afc-bridge/contracts/AfterCoin.json
Normal file
357
services/afc-bridge/contracts/AfterCoin.json
Normal 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
1358
services/afc-bridge/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
services/afc-bridge/package.json
Normal file
15
services/afc-bridge/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
23
services/afc-bridge/src/config.js
Normal file
23
services/afc-bridge/src/config.js
Normal 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;
|
||||
64
services/afc-bridge/src/db.js
Normal file
64
services/afc-bridge/src/db.js
Normal 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,
|
||||
};
|
||||
33
services/afc-bridge/src/deploy.js
Normal file
33
services/afc-bridge/src/deploy.js
Normal 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);
|
||||
});
|
||||
64
services/afc-bridge/src/ethereum.js
Normal file
64
services/afc-bridge/src/ethereum.js
Normal 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,
|
||||
};
|
||||
79
services/afc-bridge/src/index.js
Normal file
79
services/afc-bridge/src/index.js
Normal 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);
|
||||
});
|
||||
31
services/afc-bridge/src/routes/balance.js
Normal file
31
services/afc-bridge/src/routes/balance.js
Normal 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;
|
||||
54
services/afc-bridge/src/routes/deposit.js
Normal file
54
services/afc-bridge/src/routes/deposit.js
Normal 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;
|
||||
60
services/afc-bridge/src/routes/register.js
Normal file
60
services/afc-bridge/src/routes/register.js
Normal 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;
|
||||
29
services/afc-bridge/src/routes/wallet.js
Normal file
29
services/afc-bridge/src/routes/wallet.js
Normal 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;
|
||||
63
services/afc-bridge/src/routes/withdraw.js
Normal file
63
services/afc-bridge/src/routes/withdraw.js
Normal 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;
|
||||
9
services/afc-bridge/src/txQueue.js
Normal file
9
services/afc-bridge/src/txQueue.js
Normal 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 };
|
||||
Reference in New Issue
Block a user