mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-10-30 06:19:48 +00:00
ci/cd(backend): offline test suite + Dockerize + audit-grade deploy (b + c)
Closes the "backend has zero CI + manual SSH deploy" audit finding for the indexer. Part B — CI-runnable tests (the integration-tests/*.live.test.js need a live chain and are excluded, not stubbed): extract injectable cores (indexer.js: DB+HTTP+decode, no @pezkuwi import; council.js: createApp factory for the council/KYC routes). New node:test suite (19/19, fully offline) — in-memory sqlite, dependency-injected chain stub, an in-memory fake-supabase, and REAL @pezkuwi keyring signatures proving 401 bad-sig / 400 msg-mismatch / 200 valid / 409 dup / 403 non-member / threshold auto-execute. Backend job now runs npm ci + npm test and is in ci-gate (failing tests block merge). Fixed a latent bug: block indexing now awaits each insert (was fire-and-forget forEach). Added runtime deps server.js imported but were missing from package.json (@supabase/supabase-js, pino, pino-http) — server.js could not have `npm ci`-run before. Part C — Dockerfile (non-root, HEALTHCHECK /health, sqlite state on a /data VOLUME kept out of the image) + backend deploy pipeline mirroring the web one: build+push to GHCR, cosign keyless sign + verify, ssh deploy with the DB volume preserved across deploys, /health poll, auto-rollback to previous SHA, same Telegram CEO approval gate, main/tags only, never fork PRs. New secrets documented in backend/DEPLOY.md (BACKEND_VPS_HOST/USER/ SSH_KEY). DB_PATH env (default ./transactions.db) lets prod point at the volume. Note for owner: if server.js (council/KYC bootstrap) is legacy/unused, its newly-added deps can be dropped instead.
This commit is contained in:
1 parent
20a5602167
commit
80d273ff11
14 files changed
+1847
-324
No files matched your search
@@ -0,0 +1,198 @@
|
||||
// Council / KYC HTTP surface — signature-gated, fund/authz-relevant.
|
||||
//
|
||||
// Extracted from server.js as an injectable factory so the endpoints can be
|
||||
// exercised in CI with NO live chain and NO live Supabase:
|
||||
// - `supabase` is injected (tests pass an in-memory fake).
|
||||
// - `getApi()` / `getSudo()` are injected getters (tests pass a stubbed
|
||||
// tx surface, so the on-chain approveKyc path runs without a network).
|
||||
// Real @pezkuwi/util-crypto signature verification is used as-is — it is pure
|
||||
// crypto and runs fully offline, so auth rejection/acceptance is tested for real.
|
||||
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import { signatureVerify } from '@pezkuwi/util-crypto'
|
||||
|
||||
const THRESHOLD_PERCENT = 0.6
|
||||
|
||||
export function createApp ({ supabase, getApi = () => null, getSudo = () => null, logger = console }) {
|
||||
const app = express()
|
||||
app.use(cors())
|
||||
app.use(express.json())
|
||||
|
||||
// ========================================
|
||||
// COUNCIL MANAGEMENT
|
||||
// ========================================
|
||||
app.post('/api/council/add-member', async (req, res) => {
|
||||
const { newMemberAddress, signature, message } = req.body
|
||||
const founderAddress = process.env.FOUNDER_ADDRESS
|
||||
|
||||
if (!founderAddress) {
|
||||
logger.error('Founder address is not configured.')
|
||||
return res.status(500).json({ error: { key: 'errors.server.founder_not_configured' } })
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
const { isValid } = signatureVerify(message, signature, founderAddress)
|
||||
if (!isValid) {
|
||||
return res.status(401).json({ error: { key: 'errors.auth.invalid_signature' } })
|
||||
}
|
||||
if (!message.includes(`addCouncilMember:${newMemberAddress}`)) {
|
||||
return res.status(400).json({ error: { key: 'errors.request.message_mismatch' } })
|
||||
}
|
||||
}
|
||||
|
||||
if (!newMemberAddress || newMemberAddress.length < 47) {
|
||||
return res.status(400).json({ error: { key: 'errors.request.invalid_address' } })
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('council_members')
|
||||
.insert([{ address: newMemberAddress }])
|
||||
|
||||
if (error) {
|
||||
if (error.code === '23505') { // Unique violation
|
||||
return res.status(409).json({ error: { key: 'errors.council.member_exists' } })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
res.status(200).json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error({ err: error, newMemberAddress }, 'Error adding council member')
|
||||
res.status(500).json({ error: { key: 'errors.server.internal_error' } })
|
||||
}
|
||||
})
|
||||
|
||||
// ========================================
|
||||
// KYC VOTING
|
||||
// ========================================
|
||||
app.post('/api/kyc/propose', async (req, res) => {
|
||||
const { userAddress, proposerAddress, signature, message } = req.body
|
||||
|
||||
try {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
const { isValid } = signatureVerify(message, signature, proposerAddress)
|
||||
if (!isValid) {
|
||||
return res.status(401).json({ error: { key: 'errors.auth.invalid_signature' } })
|
||||
}
|
||||
if (!message.includes(`proposeKYC:${userAddress}`)) {
|
||||
return res.status(400).json({ error: { key: 'errors.request.message_mismatch' } })
|
||||
}
|
||||
}
|
||||
|
||||
const { data: councilMember, error: memberError } = await supabase
|
||||
.from('council_members').select('address').eq('address', proposerAddress).single()
|
||||
|
||||
if (memberError || !councilMember) {
|
||||
return res.status(403).json({ error: { key: 'errors.auth.proposer_not_member' } })
|
||||
}
|
||||
|
||||
const { error: proposalError } = await supabase
|
||||
.from('kyc_proposals').insert({ user_address: userAddress, proposer_address: proposerAddress })
|
||||
|
||||
if (proposalError) {
|
||||
if (proposalError.code === '23505') {
|
||||
return res.status(409).json({ error: { key: 'errors.kyc.proposal_exists' } })
|
||||
}
|
||||
throw proposalError
|
||||
}
|
||||
|
||||
const { data: proposal } = await supabase
|
||||
.from('kyc_proposals').select('id').eq('user_address', userAddress).single()
|
||||
|
||||
await supabase.from('votes')
|
||||
.insert({ proposal_id: proposal.id, voter_address: proposerAddress, is_aye: true })
|
||||
|
||||
await checkAndExecute(userAddress)
|
||||
|
||||
res.status(201).json({ success: true, proposalId: proposal.id })
|
||||
} catch (error) {
|
||||
logger.error({ err: error, ...req.body }, 'Error proposing KYC')
|
||||
res.status(500).json({ error: { key: 'errors.server.internal_error' } })
|
||||
}
|
||||
})
|
||||
|
||||
async function checkAndExecute (userAddress) {
|
||||
try {
|
||||
const api = getApi()
|
||||
const sudoAccount = getSudo()
|
||||
|
||||
const { count: totalMembers, error: countError } = await supabase
|
||||
.from('council_members').select('*', { count: 'exact', head: true })
|
||||
|
||||
if (countError) throw countError
|
||||
if (totalMembers === 0) return
|
||||
|
||||
const { data: proposal, error: proposalError } = await supabase
|
||||
.from('kyc_proposals').select('id, executed').eq('user_address', userAddress).single()
|
||||
|
||||
if (proposalError || !proposal || proposal.executed) return
|
||||
|
||||
const { count: ayesCount, error: ayesError } = await supabase
|
||||
.from('votes').select('*', { count: 'exact', head: true })
|
||||
.eq('proposal_id', proposal.id).eq('is_aye', true)
|
||||
|
||||
if (ayesError) throw ayesError
|
||||
|
||||
const requiredVotes = Math.ceil(totalMembers * THRESHOLD_PERCENT)
|
||||
|
||||
if (ayesCount >= requiredVotes) {
|
||||
if (!sudoAccount || !api) {
|
||||
logger.error({ userAddress }, 'Cannot execute: No sudo account or API connection')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info({ userAddress }, 'Threshold reached! Executing approveKyc...')
|
||||
const tx = api.tx.identityKyc.approveKyc(userAddress)
|
||||
|
||||
await tx.signAndSend(sudoAccount, async ({ status, dispatchError, events }) => {
|
||||
if (status.isFinalized) {
|
||||
if (dispatchError) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule)
|
||||
const errorMsg = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`
|
||||
logger.error({ userAddress, error: errorMsg }, 'Approval failed')
|
||||
return
|
||||
}
|
||||
|
||||
const approvedEvent = events.find(({ event }) => api.events.identityKyc.KycApproved.is(event))
|
||||
if (approvedEvent) {
|
||||
logger.info({ userAddress }, 'KYC Approved on-chain. Marking as executed.')
|
||||
await supabase.from('kyc_proposals').update({ executed: true }).eq('id', proposal.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userAddress }, 'Error in checkAndExecute')
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// GETTERS
|
||||
// ========================================
|
||||
app.get('/api/kyc/pending', async (req, res) => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('kyc_proposals')
|
||||
.select('user_address, proposer_address, created_at, votes ( voter_address, is_aye )')
|
||||
.eq('executed', false)
|
||||
if (error) throw error
|
||||
res.json({ pending: data })
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Error fetching pending proposals')
|
||||
res.status(500).json({ error: { key: 'errors.server.internal_error' } })
|
||||
}
|
||||
})
|
||||
|
||||
// ========================================
|
||||
// HEALTH CHECK
|
||||
// ========================================
|
||||
app.get('/health', async (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
blockchain: getApi() ? 'connected' : 'disconnected'
|
||||
})
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
+32
-124
@@ -1,134 +1,42 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { open } from 'sqlite';
|
||||
import sqlite3 from 'sqlite3';
|
||||
import { ApiPromise, WsProvider } from '@pezkuwi/api';
|
||||
import dotenv from 'dotenv';
|
||||
// Indexer service bootstrap.
|
||||
//
|
||||
// Thin wiring layer: connects to the live chain and feeds decoded blocks into
|
||||
// the injectable core in ./indexer.js (which holds all the DB + HTTP logic and
|
||||
// is unit-tested offline). Keeping the @pezkuwi/api connection isolated here
|
||||
// means the testable core never touches the network.
|
||||
|
||||
dotenv.config();
|
||||
import { ApiPromise, WsProvider } from '@pezkuwi/api'
|
||||
import dotenv from 'dotenv'
|
||||
import { initDb, indexBlock, createApp } from './indexer.js'
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3001;
|
||||
const WS_ENDPOINT = process.env.WS_ENDPOINT || 'wss://rpc.pezkuwichain.io';
|
||||
dotenv.config()
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Initialize Database
|
||||
async function initDb() {
|
||||
const db = await open({
|
||||
filename: './transactions.db',
|
||||
driver: sqlite3.Database
|
||||
});
|
||||
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS transfers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hash TEXT UNIQUE,
|
||||
sender TEXT,
|
||||
receiver TEXT,
|
||||
amount TEXT,
|
||||
asset_id INTEGER DEFAULT NULL,
|
||||
symbol TEXT,
|
||||
block_number INTEGER,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
return db;
|
||||
}
|
||||
const port = process.env.PORT || 3001
|
||||
const WS_ENDPOINT = process.env.WS_ENDPOINT || 'wss://rpc.pezkuwichain.io'
|
||||
|
||||
// Start Indexing
|
||||
async function startIndexer(db) {
|
||||
console.log(`Connecting to Pezkuwi Node: ${WS_ENDPOINT}`);
|
||||
const provider = new WsProvider(WS_ENDPOINT);
|
||||
const api = await ApiPromise.create({ provider });
|
||||
async function startIndexer (db) {
|
||||
console.log(`Connecting to Pezkuwi Node: ${WS_ENDPOINT}`)
|
||||
const provider = new WsProvider(WS_ENDPOINT)
|
||||
const api = await ApiPromise.create({ provider })
|
||||
|
||||
console.log('Connected! Listening for new blocks...');
|
||||
console.log('Connected! Listening for new blocks...')
|
||||
|
||||
api.rpc.chain.subscribeNewHeads(async (header) => {
|
||||
const blockNumber = header.number.toNumber();
|
||||
const blockHash = await api.rpc.chain.getBlockHash(blockNumber);
|
||||
const signedBlock = await api.rpc.chain.getBlock(blockHash);
|
||||
|
||||
signedBlock.block.extrinsics.forEach(async (ex) => {
|
||||
const { method: { method, section }, signer } = ex;
|
||||
|
||||
// 1. Handle Native HEZ Transfers
|
||||
if (section === 'balances' && (method === 'transfer' || method === 'transferKeepAlive')) {
|
||||
const [dest, value] = ex.method.args;
|
||||
await saveTransfer(db, {
|
||||
hash: ex.hash.toHex(),
|
||||
sender: signer.toString(),
|
||||
receiver: dest.toString(),
|
||||
amount: value.toString(),
|
||||
asset_id: null,
|
||||
symbol: 'HEZ',
|
||||
block_number: blockNumber
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Handle Asset Transfers (PEZ, USDT)
|
||||
if (section === 'assets' && method === 'transfer') {
|
||||
const [id, dest, value] = ex.method.args;
|
||||
const assetId = id.toNumber();
|
||||
const symbol = assetId === 1 ? 'PEZ' : assetId === 1000 ? 'USDT' : `ASSET-${assetId}`;
|
||||
|
||||
await saveTransfer(db, {
|
||||
hash: ex.hash.toHex(),
|
||||
sender: signer.toString(),
|
||||
receiver: dest.toString(),
|
||||
amount: value.toString(),
|
||||
asset_id: assetId,
|
||||
symbol: symbol,
|
||||
block_number: blockNumber
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function saveTransfer(db, tx) {
|
||||
try {
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO transfers (hash, sender, receiver, amount, asset_id, symbol, block_number)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[tx.hash, tx.sender, tx.receiver, tx.amount, tx.asset_id, tx.symbol, tx.block_number]
|
||||
);
|
||||
console.log(`Indexed ${tx.symbol} Transfer: ${tx.hash.slice(0, 10)}...`);
|
||||
} catch (err) {
|
||||
console.error('DB Insert Error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// API Routes
|
||||
async function startServer(db) {
|
||||
app.get('/api/history/:address', async (req, res) => {
|
||||
const { address } = req.params;
|
||||
try {
|
||||
const history = await db.all(
|
||||
`SELECT * FROM transfers
|
||||
WHERE sender = ? OR receiver = ?
|
||||
ORDER BY block_number DESC LIMIT 50`,
|
||||
[address, address]
|
||||
);
|
||||
res.json(history);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/stats', async (req, res) => {
|
||||
const stats = await db.get('SELECT COUNT(*) as total FROM transfers');
|
||||
res.json(stats);
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Indexer API running at http://localhost:${port}`);
|
||||
});
|
||||
api.rpc.chain.subscribeNewHeads(async (header) => {
|
||||
const blockNumber = header.number.toNumber()
|
||||
const blockHash = await api.rpc.chain.getBlockHash(blockNumber)
|
||||
const signedBlock = await api.rpc.chain.getBlock(blockHash)
|
||||
await indexBlock(db, signedBlock, blockNumber)
|
||||
})
|
||||
}
|
||||
|
||||
// Launch
|
||||
const db = await initDb();
|
||||
startIndexer(db);
|
||||
startServer(db);
|
||||
// DB_PATH lets the deploy point the stateful sqlite file at a mounted volume
|
||||
// (e.g. /data/transactions.db); defaults to the historical ./transactions.db.
|
||||
const db = await initDb(process.env.DB_PATH || './transactions.db')
|
||||
startIndexer(db)
|
||||
|
||||
const app = createApp(db)
|
||||
app.listen(port, () => {
|
||||
console.log(`Indexer API running at http://localhost:${port}`)
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
// Indexer core — pure, injectable, offline-testable.
|
||||
//
|
||||
// This module deliberately does NOT import @pezkuwi/api. It operates on
|
||||
// already-decoded extrinsic objects and an injected sqlite handle, so the
|
||||
// dedup / symbol-mapping / DB logic can be exercised in CI with no live chain
|
||||
// and an in-memory (`:memory:`) database. src/index.js wires this to the live
|
||||
// WsProvider/ApiPromise subscription.
|
||||
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import { open } from 'sqlite'
|
||||
import sqlite3 from 'sqlite3'
|
||||
|
||||
// Open (or create) the transfers DB. Pass ':memory:' in tests.
|
||||
export async function initDb (filename = './transactions.db') {
|
||||
const db = await open({ filename, driver: sqlite3.Database })
|
||||
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS transfers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hash TEXT UNIQUE,
|
||||
sender TEXT,
|
||||
receiver TEXT,
|
||||
amount TEXT,
|
||||
asset_id INTEGER DEFAULT NULL,
|
||||
symbol TEXT,
|
||||
block_number INTEGER,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
// Persist a single transfer. INSERT OR IGNORE gives us hash-level dedup so the
|
||||
// same extrinsic seen twice (re-org / replay / overlapping subscriptions) is a
|
||||
// no-op instead of a duplicate row.
|
||||
export async function saveTransfer (db, tx) {
|
||||
try {
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO transfers (hash, sender, receiver, amount, asset_id, symbol, block_number)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[tx.hash, tx.sender, tx.receiver, tx.amount, tx.asset_id, tx.symbol, tx.block_number]
|
||||
)
|
||||
console.log(`Indexed ${tx.symbol} Transfer: ${tx.hash.slice(0, 10)}...`)
|
||||
} catch (err) {
|
||||
console.error('DB Insert Error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Decode a single (decoded) extrinsic into a transfer row, or null if it is not
|
||||
// a transfer we index. Kept pure so it can be unit-tested with plain fixtures.
|
||||
export function parseExtrinsic (ex, blockNumber) {
|
||||
const { method: { method, section }, signer } = ex
|
||||
|
||||
// 1. Native HEZ transfers
|
||||
if (section === 'balances' && (method === 'transfer' || method === 'transferKeepAlive')) {
|
||||
const [dest, value] = ex.method.args
|
||||
return {
|
||||
hash: ex.hash.toHex(),
|
||||
sender: signer.toString(),
|
||||
receiver: dest.toString(),
|
||||
amount: value.toString(),
|
||||
asset_id: null,
|
||||
symbol: 'HEZ',
|
||||
block_number: blockNumber
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Asset transfers (PEZ, USDT, …)
|
||||
if (section === 'assets' && method === 'transfer') {
|
||||
const [id, dest, value] = ex.method.args
|
||||
const assetId = id.toNumber()
|
||||
const symbol = assetId === 1 ? 'PEZ' : assetId === 1000 ? 'USDT' : `ASSET-${assetId}`
|
||||
return {
|
||||
hash: ex.hash.toHex(),
|
||||
sender: signer.toString(),
|
||||
receiver: dest.toString(),
|
||||
amount: value.toString(),
|
||||
asset_id: assetId,
|
||||
symbol,
|
||||
block_number: blockNumber
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Index every relevant extrinsic in a block. Sequential await (vs the original
|
||||
// fire-and-forget forEach) so inserts actually complete before the block is
|
||||
// considered processed — same rows, just no lost/racing writes.
|
||||
export async function indexBlock (db, signedBlock, blockNumber) {
|
||||
for (const ex of signedBlock.block.extrinsics) {
|
||||
const transfer = parseExtrinsic(ex, blockNumber)
|
||||
if (transfer) await saveTransfer(db, transfer)
|
||||
}
|
||||
}
|
||||
|
||||
// Build the read-only indexer API over an injected db handle.
|
||||
export function createApp (db) {
|
||||
const app = express()
|
||||
app.use(cors())
|
||||
app.use(express.json())
|
||||
|
||||
// Liveness/readiness probe (used by the Docker HEALTHCHECK).
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok' })
|
||||
})
|
||||
|
||||
app.get('/api/history/:address', async (req, res) => {
|
||||
const { address } = req.params
|
||||
try {
|
||||
const history = await db.all(
|
||||
`SELECT * FROM transfers
|
||||
WHERE sender = ? OR receiver = ?
|
||||
ORDER BY block_number DESC LIMIT 50`,
|
||||
[address, address]
|
||||
)
|
||||
res.json(history)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/stats', async (req, res) => {
|
||||
try {
|
||||
const stats = await db.get('SELECT COUNT(*) as total FROM transfers')
|
||||
res.json(stats)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
+19
-189
@@ -1,11 +1,17 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
// Council / KYC service bootstrap.
|
||||
//
|
||||
// Thin wiring layer: builds the real Supabase client + logger, connects to the
|
||||
// live chain, and mounts the injectable route factory from ./council.js. All
|
||||
// endpoint logic lives in council.js so it can be tested offline; this file only
|
||||
// owns the live side effects (Supabase client, blockchain connection).
|
||||
|
||||
import dotenv from 'dotenv'
|
||||
import pino from 'pino'
|
||||
import pinoHttp from 'pino-http'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { ApiPromise, WsProvider, Keyring } from '@pezkuwi/api'
|
||||
import { cryptoWaitReady, signatureVerify } from '@pezkuwi/util-crypto'
|
||||
import { cryptoWaitReady } from '@pezkuwi/util-crypto'
|
||||
import { createApp } from './council.js'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
@@ -25,7 +31,6 @@ const logger = pino({
|
||||
// ========================================
|
||||
// INITIALIZATION
|
||||
// ========================================
|
||||
|
||||
const supabaseUrl = process.env.SUPABASE_URL
|
||||
const supabaseKey = process.env.SUPABASE_ANON_KEY
|
||||
if (!supabaseUrl || !supabaseKey) {
|
||||
@@ -34,19 +39,21 @@ if (!supabaseUrl || !supabaseKey) {
|
||||
}
|
||||
const supabase = createClient(supabaseUrl, supabaseKey)
|
||||
|
||||
const app = express()
|
||||
app.use(cors())
|
||||
app.use(express.json())
|
||||
app.use(pinoHttp({ logger }))
|
||||
|
||||
const THRESHOLD_PERCENT = 0.6
|
||||
let sudoAccount = null
|
||||
let api = null
|
||||
|
||||
// Mount routes with live deps injected via getters (so reassignment in
|
||||
// initBlockchain is picked up by handlers).
|
||||
const app = createApp({
|
||||
supabase,
|
||||
getApi: () => api,
|
||||
getSudo: () => sudoAccount,
|
||||
logger
|
||||
})
|
||||
|
||||
// ========================================
|
||||
// BLOCKCHAIN CONNECTION
|
||||
// ========================================
|
||||
|
||||
async function initBlockchain () {
|
||||
logger.info('🔗 Connecting to Blockchain...')
|
||||
const wsProvider = new WsProvider(process.env.WS_ENDPOINT || 'ws://127.0.0.1:9944')
|
||||
@@ -63,189 +70,12 @@ async function initBlockchain () {
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// COUNCIL MANAGEMENT
|
||||
// ========================================
|
||||
|
||||
app.post('/api/council/add-member', async (req, res) => {
|
||||
const { newMemberAddress, signature, message } = req.body
|
||||
const founderAddress = process.env.FOUNDER_ADDRESS
|
||||
|
||||
if (!founderAddress) {
|
||||
logger.error('Founder address is not configured.')
|
||||
return res.status(500).json({ error: { key: 'errors.server.founder_not_configured' } })
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
const { isValid } = signatureVerify(message, signature, founderAddress)
|
||||
if (!isValid) {
|
||||
return res.status(401).json({ error: { key: 'errors.auth.invalid_signature' } })
|
||||
}
|
||||
if (!message.includes(`addCouncilMember:${newMemberAddress}`)) {
|
||||
return res.status(400).json({ error: { key: 'errors.request.message_mismatch' } })
|
||||
}
|
||||
}
|
||||
|
||||
if (!newMemberAddress || newMemberAddress.length < 47) {
|
||||
return res.status(400).json({ error: { key: 'errors.request.invalid_address' } })
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('council_members')
|
||||
.insert([{ address: newMemberAddress }])
|
||||
|
||||
if (error) {
|
||||
if (error.code === '23505') { // Unique violation
|
||||
return res.status(409).json({ error: { key: 'errors.council.member_exists' } })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
res.status(200).json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error({ err: error, newMemberAddress }, 'Error adding council member')
|
||||
res.status(500).json({ error: { key: 'errors.server.internal_error' } })
|
||||
}
|
||||
})
|
||||
|
||||
// ========================================
|
||||
// KYC VOTING
|
||||
// ========================================
|
||||
|
||||
app.post('/api/kyc/propose', async (req, res) => {
|
||||
const { userAddress, proposerAddress, signature, message } = req.body
|
||||
|
||||
try {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
const { isValid } = signatureVerify(message, signature, proposerAddress)
|
||||
if (!isValid) {
|
||||
return res.status(401).json({ error: { key: 'errors.auth.invalid_signature' } })
|
||||
}
|
||||
if (!message.includes(`proposeKYC:${userAddress}`)) {
|
||||
return res.status(400).json({ error: { key: 'errors.request.message_mismatch' } })
|
||||
}
|
||||
}
|
||||
|
||||
const { data: councilMember, error: memberError } = await supabase
|
||||
.from('council_members').select('address').eq('address', proposerAddress).single()
|
||||
|
||||
if (memberError || !councilMember) {
|
||||
return res.status(403).json({ error: { key: 'errors.auth.proposer_not_member' } })
|
||||
}
|
||||
|
||||
const { error: proposalError } = await supabase
|
||||
.from('kyc_proposals').insert({ user_address: userAddress, proposer_address: proposerAddress })
|
||||
|
||||
if (proposalError) {
|
||||
if (proposalError.code === '23505') {
|
||||
return res.status(409).json({ error: { key: 'errors.kyc.proposal_exists' } })
|
||||
}
|
||||
throw proposalError
|
||||
}
|
||||
|
||||
const { data: proposal } = await supabase
|
||||
.from('kyc_proposals').select('id').eq('user_address', userAddress).single()
|
||||
|
||||
await supabase.from('votes')
|
||||
.insert({ proposal_id: proposal.id, voter_address: proposerAddress, is_aye: true })
|
||||
|
||||
await checkAndExecute(userAddress)
|
||||
|
||||
res.status(201).json({ success: true, proposalId: proposal.id })
|
||||
} catch (error) {
|
||||
logger.error({ err: error, ...req.body }, 'Error proposing KYC')
|
||||
res.status(500).json({ error: { key: 'errors.server.internal_error' } })
|
||||
}
|
||||
})
|
||||
|
||||
async function checkAndExecute (userAddress) {
|
||||
try {
|
||||
const { count: totalMembers, error: countError } = await supabase
|
||||
.from('council_members').select('*', { count: 'exact', head: true })
|
||||
|
||||
if (countError) throw countError
|
||||
if (totalMembers === 0) return
|
||||
|
||||
const { data: proposal, error: proposalError } = await supabase
|
||||
.from('kyc_proposals').select('id, executed').eq('user_address', userAddress).single()
|
||||
|
||||
if (proposalError || !proposal || proposal.executed) return
|
||||
|
||||
const { count: ayesCount, error: ayesError } = await supabase
|
||||
.from('votes').select('*', { count: 'exact', head: true })
|
||||
.eq('proposal_id', proposal.id).eq('is_aye', true)
|
||||
|
||||
if (ayesError) throw ayesError
|
||||
|
||||
const requiredVotes = Math.ceil(totalMembers * THRESHOLD_PERCENT)
|
||||
|
||||
if (ayesCount >= requiredVotes) {
|
||||
if (!sudoAccount || !api) {
|
||||
logger.error({ userAddress }, 'Cannot execute: No sudo account or API connection')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info({ userAddress }, `Threshold reached! Executing approveKyc...`)
|
||||
const tx = api.tx.identityKyc.approveKyc(userAddress)
|
||||
|
||||
await tx.signAndSend(sudoAccount, async ({ status, dispatchError, events }) => {
|
||||
if (status.isFinalized) {
|
||||
if (dispatchError) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule)
|
||||
const errorMsg = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`
|
||||
logger.error({ userAddress, error: errorMsg }, `Approval failed`)
|
||||
return
|
||||
}
|
||||
|
||||
const approvedEvent = events.find(({ event }) => api.events.identityKyc.KycApproved.is(event))
|
||||
if (approvedEvent) {
|
||||
logger.info({ userAddress }, 'KYC Approved on-chain. Marking as executed.')
|
||||
await supabase.from('kyc_proposals').update({ executed: true }).eq('id', proposal.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userAddress }, `Error in checkAndExecute`)
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// OTHER ENDPOINTS (GETTERS)
|
||||
// ========================================
|
||||
|
||||
app.get('/api/kyc/pending', async (req, res) => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('kyc_proposals')
|
||||
.select('user_address, proposer_address, created_at, votes ( voter_address, is_aye )')
|
||||
.eq('executed', false)
|
||||
if (error) throw error
|
||||
res.json({ pending: data })
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Error fetching pending proposals')
|
||||
res.status(500).json({ error: { key: 'errors.server.internal_error' } })
|
||||
}
|
||||
})
|
||||
|
||||
// ========================================
|
||||
// HEALTH CHECK
|
||||
// ========================================
|
||||
|
||||
app.get('/health', async (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
blockchain: api ? 'connected' : 'disconnected'
|
||||
});
|
||||
})
|
||||
|
||||
// ========================================
|
||||
// START & EXPORT
|
||||
// ========================================
|
||||
|
||||
initBlockchain().catch(error => {
|
||||
logger.fatal({ err: error }, '❌ Failed to initialize blockchain')
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
export { app, supabase, api, logger }
|
||||
export { app, supabase, logger }
|
||||
Reference in new issue
Block a user