diff --git a/.env.example b/.env.example index b701f8a89..305de64c0 100644 --- a/.env.example +++ b/.env.example @@ -214,6 +214,8 @@ TLSNOTARY_FATAL=false # In docker mode (the default), the tlsnotary sidecar container manages its # own key and exposes the public key via GET /info — leave this empty. TLSNOTARY_SIGNING_KEY= +TLSNOTARY_PROXY_URL= +TLSNOTARY_EXPOSED_URL= # === LOGGING & MISC ========================================================= diff --git a/src/config/loader.ts b/src/config/loader.ts index 2e3aec6a9..50f4484fd 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -116,6 +116,8 @@ export function loadConfig(): Readonly { tlsnotary: { enabled: envBool(EnvKey.TLSNOTARY_ENABLED, d.tlsnotary.enabled), host: envStr(EnvKey.TLSNOTARY_HOST, d.tlsnotary.host), + exposedUrl: envStr(EnvKey.TLSNOTARY_EXPOSED_URL, d.tlsnotary.exposedUrl), + proxyUrl: envStr(EnvKey.TLSNOTARY_PROXY_URL, d.tlsnotary.proxyUrl), port: envInt(EnvKey.TLSNOTARY_PORT, d.tlsnotary.port), signingKey: envStr(EnvKey.TLSNOTARY_SIGNING_KEY, d.tlsnotary.signingKey), fatal: envBool(EnvKey.TLSNOTARY_FATAL, d.tlsnotary.fatal), diff --git a/src/config/types.ts b/src/config/types.ts index d3b0dd2e7..9a3d3f434 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -74,6 +74,8 @@ export interface CoreConfig { export interface TLSNotaryConfig { enabled: boolean + exposedUrl: string + proxyUrl: string host: string port: number signingKey: string diff --git a/src/errors/sources.ts b/src/errors/sources.ts index 6eddf1533..01e288ff9 100644 --- a/src/errors/sources.ts +++ b/src/errors/sources.ts @@ -59,6 +59,8 @@ export const ErrorSource = { METRICS_SHUTDOWN: "Metrics shutdown", RPC_SHUTDOWN: "RPC shutdown", SIGNALING_SHUTDOWN: "Signaling shutdown", + WORKER_POOL_STARTUP: "TxValidatorPool startup", + WORKER_POOL_SHUTDOWN: "TxValidatorPool shutdown", // --- Identity --- IDENTITY_VERIFICATION: "identity verification", diff --git a/src/features/InstantMessagingProtocol/signalingServer/signalingServer.ts b/src/features/InstantMessagingProtocol/signalingServer/signalingServer.ts index 03300f9c6..d74c547c1 100644 --- a/src/features/InstantMessagingProtocol/signalingServer/signalingServer.ts +++ b/src/features/InstantMessagingProtocol/signalingServer/signalingServer.ts @@ -74,6 +74,7 @@ import { OfflineMessage } from "@/model/entities/OfflineMessages" import { deserializeUint8Array } from "@kynesyslabs/demosdk/utils" // FIXME Import from the sdk once we can import log from "@/utilities/logger" import { handleError } from "@/errors" +import TxValidatorPool from "@/libs/blockchain/validation/txValidatorPool" import { serializeTransactionContent } from "@/forks" /** * SignalingServer class that manages peer connections and message routing @@ -324,7 +325,7 @@ export class SignalingServer { const signingPublicKey = deserializedProof.publicKey // Validate the proof - const verified = await ucrypto.verify(deserializedProof) + const verified = await TxValidatorPool.getInstance().verify(deserializedProof) if (!verified) { this.sendError(ws, ImErrorType.INVALID_PROOF, "Invalid proof") @@ -677,7 +678,7 @@ export class SignalingServer { transaction.hash = Hashing.sha256( serializeTransactionContent(transaction.content, referenceBlock), ) - const signature = await ucrypto.sign( + const signature = await TxValidatorPool.getInstance().sign( getSharedState.signingAlgorithm, new TextEncoder().encode(transaction.hash), ) diff --git a/src/features/incentive/PointSystem.ts b/src/features/incentive/PointSystem.ts index f87d7922b..1784ef9f0 100644 --- a/src/features/incentive/PointSystem.ts +++ b/src/features/incentive/PointSystem.ts @@ -283,7 +283,6 @@ export class PointSystem { twitterUserId?: string, ): Promise { const db = await Datasource.getInstance() - const gcrMainRepository = db.getDataSource().getRepository(GCRMain) const account = await ensureGCRForUser(userId) const isEligibleForReferral = Referrals.isEligibleForReferral(account) @@ -375,9 +374,10 @@ export class PointSystem { if (appliedDelta !== 0) { account.points.totalPoints = oldTotal + appliedDelta + account.points.lastUpdated = new Date() } - account.points.lastUpdated = new Date() + const gcrMainRepository = db.getDataSource().getRepository(GCRMain) // Process referral for existing account if eligible if (referralCode && isEligibleForReferral) { diff --git a/src/features/l2ps-messaging/L2PSMessagingServer.ts b/src/features/l2ps-messaging/L2PSMessagingServer.ts index b8e60fd7e..5bb24b8aa 100644 --- a/src/features/l2ps-messaging/L2PSMessagingServer.ts +++ b/src/features/l2ps-messaging/L2PSMessagingServer.ts @@ -19,6 +19,7 @@ import type { HistoryMessage, ErrorCode, } from "./types" +import TxValidatorPool from "@/libs/blockchain/validation/txValidatorPool" /** Max raw WebSocket message size (256 KB) */ const MAX_MESSAGE_SIZE = 256 * 1024 @@ -138,7 +139,7 @@ export class L2PSMessagingServer { // Verify proof of key ownership: sign("register:{publicKey}:{timestamp}") const proofMessage = `register:${publicKey}:${msg.timestamp}` try { - const valid = await ucrypto.verify({ + const valid = await TxValidatorPool.getInstance().verify({ algorithm: getSharedState.signingAlgorithm, message: new TextEncoder().encode(proofMessage), publicKey: this.hexToUint8Array(publicKey), @@ -288,7 +289,7 @@ export class L2PSMessagingServer { // Verify proof: sign("history:{peerKey}:{timestamp}") const proofMessage = `history:${peerKey}:${msg.timestamp}` try { - const valid = await ucrypto.verify({ + const valid = await TxValidatorPool.getInstance().verify({ algorithm: getSharedState.signingAlgorithm, message: new TextEncoder().encode(proofMessage), publicKey: this.hexToUint8Array(myKey), diff --git a/src/features/tlsnotary/proxyManager.ts b/src/features/tlsnotary/proxyManager.ts index 6e819d8c6..7110b2287 100644 --- a/src/features/tlsnotary/proxyManager.ts +++ b/src/features/tlsnotary/proxyManager.ts @@ -170,19 +170,21 @@ export function extractDomainAndPort(targetUrl: string): { /** * Build a public WebSocket URL from a base HTTP(S)/WS(S) URL and a local port. - * Path mode: when the base URL has a path, route through a reverse proxy at - * `url.host` (port preserved) that maps `/` to the local port - * (single nginx rule). No-path mode: connect directly to `url.hostname` on - * the target port. + * Local bases keep direct ws://localhost: URLs for development. Public + * bases always route through a TLS reverse proxy path, defaulting to + * /tlsn// when no explicit path is provided. */ export function buildWsUrl(base: string, port: number | string): string { const url = new URL(base) - const secure = url.protocol === "https:" || url.protocol === "wss:" - const wsScheme = secure ? "wss" : "ws" - const path = url.pathname.replace(/\/+$/, "") - return path - ? `${wsScheme}://${url.host}${path}/${port}/` - : `${wsScheme}://${url.hostname}:${port}` + const isLocal = + url.hostname === "localhost" || url.hostname === "127.0.0.1" + + if (isLocal) { + return `ws://${url.hostname}:${port}` + } + + const path = url.pathname.replace(/\/+$/, "") || "/tlsn" + return `wss://${url.hostname}${path}/${port}/` } /** @@ -659,4 +661,4 @@ export function getProxyManagerStatus(): { remaining, }, } -} +} \ No newline at end of file diff --git a/src/forks/migrations/osDenomination.ts b/src/forks/migrations/osDenomination.ts index 77cc908e0..ca7a70ea3 100644 --- a/src/forks/migrations/osDenomination.ts +++ b/src/forks/migrations/osDenomination.ts @@ -18,13 +18,7 @@ KyneSys Labs: https://www.kynesys.xyz/ * Three balance-bearing sources are migrated: * * 1. **GCRv2 `gcr_main.balance`** (`bigint`) — single UPDATE × 10^9. - * 2. **Legacy `global_change_registry.details.content.balance`** (JSONB - * storing a JS `number`) — row-by-row with a CAP policy. JS numbers - * can't safely hold > `Number.MAX_SAFE_INTEGER` (2^53-1), so any - * account whose post-multiplication value would exceed - * `Math.floor(MAX_SAFE_INTEGER * 0.9)` is capped at that value and - * the lost OS amount is recorded loudly in logs and `fork_state`. - * 3. **`validators.staked_amount`** (`text`, bigint-as-string) — single + * 2. **`validators.staked_amount`** (`text`, bigint-as-string) — single * UPDATE that multiplies the cast numeric × 10^9. * * The migration runs **inside the caller-provided EntityManager**. This is @@ -190,7 +184,7 @@ export async function computePreSumDem( }> { let sum = 0n let gcrV2RowCount = 0 - let legacyRowCount = 0 + const legacyRowCount = 0 let validatorsRowCount = 0 // GCRv2 — bigint column. Rows where balance is null are tolerated as 0. @@ -202,21 +196,6 @@ export async function computePreSumDem( sum += toBigInt(row.balance) } - // Legacy GCR — JSONB `details` column with `content.balance` as a JS - // number. We read the whole row and grab `details.content.balance`. - const legacyRows: Array<{ - id: number - details: unknown - }> = await entityManager.query( - "SELECT id, details FROM global_change_registry", - ) - for (const row of legacyRows) { - legacyRowCount += 1 - const balance = readLegacyBalance(row.details) - if (balance === null) continue - sum += BigInt(Math.trunc(balance)) - } - // Validators stake — text bigint-as-string. Malformed rows are skipped // so the post-sum invariant remains valid. const validatorRows: Array<{ staked_amount: string | null }> = @@ -254,15 +233,6 @@ export async function computePostSumOs( sum += toBigInt(row.balance) } - const legacyRows: Array<{ details: unknown }> = await entityManager.query( - "SELECT details FROM global_change_registry", - ) - for (const row of legacyRows) { - const balance = readLegacyBalance(row.details) - if (balance === null) continue - sum += BigInt(Math.trunc(balance)) - } - const validatorRows: Array<{ staked_amount: string | null }> = await entityManager.query("SELECT staked_amount FROM validators") for (const row of validatorRows) { @@ -329,60 +299,8 @@ export async function runOsDenominationMigration( `[forks][osDenomination] gcr_main migrated (rows=${gcrV2RowCount})`, ) - // 4. Migrate legacy GCR row-by-row (CAP policy). - let cappedCount = 0 - let totalValueLostOs = 0n - const legacyRows: Array<{ id: number; details: unknown }> = - await entityManager.query( - "SELECT id, details FROM global_change_registry", - ) - for (const row of legacyRows) { - const parsed = parseLegacyDetails(row.details) - if (parsed === null) continue - const balance = parsed?.content?.balance - if (typeof balance !== "number" || !isFinite(balance)) continue - - const preBalanceDem = BigInt(Math.trunc(balance)) - const uncappedOs = preBalanceDem * OS_PER_DEM - - let postBalanceOs = uncappedOs - if (uncappedOs > LEGACY_NUMBER_CAP) { - postBalanceOs = LEGACY_NUMBER_CAP - const valueLost = uncappedOs - LEGACY_NUMBER_CAP - cappedCount += 1 - totalValueLostOs += valueLost - log.warning( - "[forks][osDenomination] CAP applied: " + - `account=${getLegacyAccountTag(parsed)} ` + - `preBalanceDem=${preBalanceDem.toString()} ` + - `postBalanceOs=${postBalanceOs.toString()} ` + - `valueLostOs=${valueLost.toString()}`, - ) - } - - // Build the new details, preserving every other field. The post-cap - // value must round-trip through JS `number`, which is fine because - // the cap is below MAX_SAFE_INTEGER by definition. - const newDetails = { - ...parsed, - content: { - ...parsed.content, - balance: Number(postBalanceOs), - }, - } - const pDetails = placeholder(entityManager, 1) - const pId = placeholder(entityManager, 2) - await entityManager.query( - `UPDATE global_change_registry SET details = ${pDetails} WHERE id = ${pId}`, - [serializeLegacyDetails(newDetails, row.details), row.id], - ) - } - log.info( - "[forks][osDenomination] global_change_registry migrated " + - `(rows=${legacyRowCount}, capped=${cappedCount}, ` + - `valueLostOs=${totalValueLostOs.toString()})`, - ) - + const cappedCount = 0 + const totalValueLostOs = 0n // 5. Migrate Validators stake — single bulk UPDATE that handles '0' // and any valid bigint string. We use raw SQL with a portable pattern: // the cast-as-text/cast-as-numeric round-trip is supported by both diff --git a/src/index.ts b/src/index.ts index d98ace476..730bdae6d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,6 +42,7 @@ import log, { TUIManager, CategorizedLogger } from "src/utilities/logger" import loadGenesisIdentities from "./libs/blockchain/routines/loadGenesisIdentities" // DTR and L2PS imports import Mempool from "./libs/blockchain/mempool" +import TxValidatorPool from "./libs/blockchain/validation/txValidatorPool" import { DTRManager } from "./libs/network/dtr/dtrmanager" import { L2PSHashService } from "./libs/l2ps/L2PSHashService" import { L2PSBatchAggregator } from "./libs/l2ps/L2PSBatchAggregator" @@ -321,7 +322,6 @@ async function warmup() { log.cleanLogs(false) log.info("[MAIN] Starting the node") - indexState.enough_peers = true // ? Review this // ANCHOR Overrides @@ -407,8 +407,6 @@ async function preMainLoop() { getSharedState.rpcFee = indexState.RPC_FEE // INFO: Initialize Unified Crypto with ed25519 private key - getSharedState.keypair = await getSharedState.identity.loadIdentity() - log.info("[BOOTSTRAP] Our identity is ready") // Log identity const publicKeyHex = uint8ArrayToHex( @@ -439,7 +437,7 @@ async function preMainLoop() { " Other peers cannot reach this node at this address.\n" + " For real network participation, set EXPOSED_URL in .env\n" + " to your public IP or DNS name (e.g. http://YOUR_IP:53550).\n" + - " See INSTALL.md → \"Joining the network\".\n" + + ' See INSTALL.md → "Joining the network".\n' + "============================================================", ) } @@ -533,6 +531,17 @@ async function main() { indexState.TUI_ENABLED = false } + getSharedState.keypair = await getSharedState.identity.loadIdentity() + try { + await TxValidatorPool.getInstance().start() + } catch (error) { + handleError(error, "CORE", { source: ErrorSource.WORKER_POOL_STARTUP }) + log.error( + "[CORE] TxValidatorPool failed to start; aborting node startup.", + ) + process.exit(1) + } + // Initialize TUI if enabled if (indexState.TUI_ENABLED) { try { @@ -1213,6 +1222,7 @@ async function main() { // INFO Starting the main routine main().catch((error: Error) => { + console.error(error) handleError(error, "CORE", { source: ErrorSource.MAIN, fatal: true }) gracefulShutdown("main_error").catch(() => { process.exit(1) @@ -1264,6 +1274,16 @@ async function gracefulShutdown(signal: string) { handleError(error, "CORE", { source: ErrorSource.L2PS_SHUTDOWN }) } + // Stop TxValidatorPool workers + try { + log.info("[CORE] Stopping TxValidatorPool...") + await TxValidatorPool.getInstance().stop(2_000) + } catch (error) { + handleError(error, "CORE", { + source: ErrorSource.WORKER_POOL_SHUTDOWN, + }) + } + // Stop OmniProtocol server if running if (indexState.omniServer) { log.info("[CORE] Stopping OmniProtocol server...") @@ -1341,6 +1361,7 @@ async function gracefulShutdown(signal: string) { // Stop HTTP rate limiter cleanup interval try { + // eslint-disable-next-line @typescript-eslint/naming-convention const { RateLimiter: HttpRateLimiter } = await import("./libs/network/middleware/rateLimiter") HttpRateLimiter.getInstance().destroy() diff --git a/src/libs/abstraction/index.ts b/src/libs/abstraction/index.ts index d6f59c9fa..8902d00bb 100644 --- a/src/libs/abstraction/index.ts +++ b/src/libs/abstraction/index.ts @@ -15,6 +15,7 @@ import { toInteger } from "lodash" import Chain from "../blockchain/chain" import fs from "fs" import { getSharedState } from "@/utilities/sharedState" +import TxValidatorPool from "../blockchain/validation/txValidatorPool" /** * Verifies telegram dual signature attestation (user + bot signatures) @@ -110,7 +111,7 @@ async function verifyTelegramProof( const { bot_address: botAddress } = attestationPayload // INFO: Verify user signature - const userSignatureValid = await ucrypto.verify({ + const userSignatureValid = await TxValidatorPool.getInstance().verify({ algorithm: "ed25519", message: new TextEncoder().encode(attestationPayload.challenge), publicKey: hexToUint8Array(attestationPayload.public_key), @@ -129,7 +130,7 @@ async function verifyTelegramProof( // Verify BOT signature against the attestation payload // The bot has already verified the user signature locally - const botSignatureValid = await ucrypto.verify({ + const botSignatureValid = await TxValidatorPool.getInstance().verify({ algorithm: signature.type, message: new TextEncoder().encode(messageToVerify), publicKey: hexToUint8Array(botAddress), // Bot's public key @@ -229,7 +230,7 @@ export async function verifyWeb2Proof( payload.proof as string, ) try { - const verified = await ucrypto.verify({ + const verified = await TxValidatorPool.getInstance().verify({ algorithm: type, message: new TextEncoder().encode(message), publicKey: hexToUint8Array(sender), diff --git a/src/libs/blockchain/chain.ts b/src/libs/blockchain/chain.ts index bac488d30..5d2a1a711 100644 --- a/src/libs/blockchain/chain.ts +++ b/src/libs/blockchain/chain.ts @@ -16,8 +16,6 @@ import { Peer } from "../peer" import Transaction from "./transaction" import { Blocks } from "src/model/entities/Blocks" import { Transactions } from "src/model/entities/Transactions" -import { GCRExtended } from "src/model/entities/GCR/GlobalChangeRegistry" -import { GlobalChangeRegistry } from "src/model/entities/GCR/GlobalChangeRegistry" import type { Operation } from "@kynesyslabs/demosdk/types" import type { TransactionContent } from "@kynesyslabs/demosdk/types" @@ -25,7 +23,6 @@ import { setupChainDb, readSql, writeSql, getBlocksRepo, getTransactionsRepo } f import * as blockOps from "./chainBlocks" import * as txOps from "./chainTransactions" import * as genesisOps from "./chainGenesis" -import * as statusOps from "./chainStatus" import type { TxStatus } from "./chainTypes" export type { TxStatus } from "./chainTypes" @@ -166,37 +163,4 @@ export default class Chain { static async generateGenesisBlock(genesisData: any): Promise { return genesisOps.generateGenesisBlock(genesisData) } - - static async generateGenesisBlocks(genesisJsons: any[]): Promise { - return genesisOps.generateGenesisBlocks(genesisJsons) - } - - static async getGenesisUniqueBlock() { - return genesisOps.getGenesisUniqueBlock() - } - - // ── Status ──────────────────────────────────────────────── - static async statusOf( - address: string, - type: number, - ): Promise { - return statusOps.statusOf(address, type) - } - - static async statusHashAt(blockNumber: number) { - return statusOps.statusHashAt(blockNumber) - } - - // ── Maintenance ─────────────────────────────────────────── - static async pruneBlocksToGenesisBlock(): Promise { - return blockOps.pruneBlocksToGenesisBlock() - } - - static async nukeGenesis(): Promise { - return blockOps.nukeGenesis() - } - - static async updateGenesisTimestamp(newTimestamp: number): Promise { - return blockOps.updateGenesisTimestamp(newTimestamp) - } } diff --git a/src/libs/blockchain/chainBlocks.ts b/src/libs/blockchain/chainBlocks.ts index e432d9ce4..d6a28f11f 100644 --- a/src/libs/blockchain/chainBlocks.ts +++ b/src/libs/blockchain/chainBlocks.ts @@ -1,15 +1,16 @@ -import { ILike, LessThan, MoreThan, QueryFailedError } from "typeorm" +import { LessThan, MoreThan } from "typeorm" import log from "src/utilities/logger" import Block from "./block" import Mempool from "./mempool" import Transaction, { toTransactionsEntity } from "./transaction" +import Transaction, { toTransactionsEntity } from "./transaction" import Datasource from "src/model/datasource" import { Blocks } from "src/model/entities/Blocks" +import { Transactions } from "src/model/entities/Transactions" import { IdentityCommitment } from "src/model/entities/GCRv2/IdentityCommitment" import { getSharedState } from "src/utilities/sharedState" import { updateMerkleTreeAfterBlock } from "@/features/zk/merkle/updateMerkleTreeAfterBlock" -import { handleError } from "src/errors" -import { getBlocksRepo, getTransactionsRepo } from "./chainDb" +import { CHUNK_TRANSACTIONS, chunkedInsert, getBlocksRepo } from "./chainDb" import { persistConfirmedTransactionProjection } from "./chainTransactions" import tallyUpgradeVotes from "./routines/tallyUpgradeVotes" import applyNetworkUpgrade from "./routines/applyNetworkUpgrade" @@ -26,6 +27,21 @@ import { } from "@/forks/migrations/gasFeeSeparation" import { isForkActive } from "@/forks/forkGates" import { isForkMachineryDisabled } from "@/forks/loadForkConfig" +import tallyUpgradeVotes from "./routines/tallyUpgradeVotes" +import applyNetworkUpgrade from "./routines/applyNetworkUpgrade" +import { loadNetworkParameters } from "./routines/loadNetworkParameters" +import { NetworkUpgrade } from "@/model/entities/NetworkUpgrade" +import { NetworkUpgradeVote } from "@/model/entities/NetworkUpgradeVote" +import { + isOsDenominationMigrationApplied, + runOsDenominationMigration, +} from "@/forks/migrations/osDenomination" +import { + isGasFeeSeparationMigrationApplied, + runGasFeeSeparationMigration, +} from "@/forks/migrations/gasFeeSeparation" +import { isForkActive } from "@/forks/forkGates" +import { isForkMachineryDisabled } from "@/forks/loadForkConfig" import type { FindManyOptions } from "typeorm" export function isGenesis(block: Block): boolean { @@ -50,23 +66,59 @@ export async function getLastBlock(): Promise { export async function getLastBlockNumber(): Promise { if (!getSharedState.lastBlockNumber) { - const lastBlock = await getLastBlock() - return lastBlock ? lastBlock.number : 0 + const block = await getBlocksRepo() + .createQueryBuilder("block") + .select("block.number") + .orderBy("block.number", "DESC") + .limit(1) + .getOne() + + return block ? block.number : 0 } + return getSharedState.lastBlockNumber } export async function getLastBlockHash() { if (!getSharedState.lastBlockHash) { - const lastBlock = await getLastBlock() - return lastBlock ? lastBlock.hash : null + const block = await getBlocksRepo() + .createQueryBuilder("block") + .select("block.hash") + .orderBy("block.number", "DESC") + .limit(1) + .getOne() + + return block ? block.hash : null } + return getSharedState.lastBlockHash } export async function getLastBlockTransactionSet(): Promise> { - const lastBlock = await getLastBlock() - return new Set(lastBlock.content.ordered_transactions) + const query = getBlocksRepo() + .createQueryBuilder("block") + .select("block.content") + + if (getSharedState.lastBlockNumber) { + query.where("block.number = :number", { + number: getSharedState.lastBlockNumber, + }) + } else { + query.orderBy("block.number", "DESC").limit(1) + } + + const block = await query.getOne() + return new Set(block?.content?.ordered_transactions ?? []) +} + +export async function getLastBlockSigners(): Promise { + const lastBlock = await getBlocksRepo().findOne({ + where: { number: getSharedState.lastBlockNumber }, + select: ["validation_data"], + }) + + const sigs = lastBlock?.validation_data?.signatures + return sigs ? Object.keys(sigs) : [] } export async function getBlocks( @@ -93,7 +145,7 @@ export async function getBlockByNumber(number: number): Promise { } export async function getBlockByHash(hash: string): Promise { - return await getBlocksRepo().findOneBy({ hash: ILike(hash) }) + return await getBlocksRepo().findOneBy({ hash: hash }) } export async function getGenesisBlock(): Promise { @@ -162,7 +214,6 @@ export async function insertBlock( cleanMempool = true, ): Promise { const blocksRepo = getBlocksRepo() - const transactionsRepo = getTransactionsRepo() const orderedTransactionsHashes = block.content.ordered_transactions const newBlock = new Blocks() @@ -216,12 +267,21 @@ export async function insertBlock( orderedTransactionsHashes, ) + // DEBUG: Confirm all transactions' blockNumber == block.number + // if (transactionEntities.some(tx => tx.blockNumber !== block.number)) { + // log.error( + // `[insertBlock] Transaction blockNumber mismatch: ${transactionEntities.map(tx => tx.blockNumber).join(", ")}`, + // ) + // process.exit(1) + // } + const db = await Datasource.getInstance() const dataSource = db.getDataSource() try { const result = await dataSource.transaction( async transactionalEntityManager => { + const saveBlockStart = Date.now() // REVIEW: P3b — fork-activation hook. Runs *before* the // block is persisted so balances are migrated atomically // with the triggering block. Either both commit or both @@ -282,61 +342,65 @@ export async function insertBlock( newBlock, ) - const queryRunner = transactionalEntityManager.queryRunner - for (let i = 0; i < transactionEntities.length; i++) { - const tx = transactionEntities[i] - const savepoint = `tx_insert_${i}` + if (block.number > getSharedState.lastBlockNumber) { + getSharedState.lastBlockNumber = block.number + getSharedState.lastBlockHash = block.hash + } - await queryRunner.query(`SAVEPOINT ${savepoint}`) - try { - const rawTransaction = Transaction.toRawTransaction( - tx, - "confirmed", - ) - // REVIEW P5a: bridge wire-shape `RawTransaction` to - // entity-shape `Transactions` (bigint amount/fees). - // Runtime payload unchanged. - await transactionalEntityManager.save( - transactionsRepo.target, - toTransactionsEntity(rawTransaction), + const saveBlockEnd = Date.now() + log.only( + `[insertBlock] Save block took ${saveBlockEnd - saveBlockStart}ms`, + ) + + const insertTransactionsStart = Date.now() + + if (transactionEntities.length > 0) { + const rawTransactions = transactionEntities.map(tx => + Transaction.toRawTransaction(tx, "confirmed"), + ) + + const { skipped } = await chunkedInsert( + transactionalEntityManager, + Transactions, + rawTransactions.map(tx => toTransactionsEntity(tx)), + CHUNK_TRANSACTIONS, + ) + if (skipped > 0) { + log.warn( + `[ChainDB] Skipped ${skipped} duplicate transaction(s) in block ${block.number}`, ) + } + + const l2psTxs = transactionEntities.filter( + tx => tx.content?.type === "l2ps_hash_update", + ) + for (const tx of l2psTxs) { await persistConfirmedTransactionProjection( tx, block.number, transactionalEntityManager, ) - await queryRunner.query( - `RELEASE SAVEPOINT ${savepoint}`, - ) - } catch (error) { - await queryRunner.query( - `ROLLBACK TO SAVEPOINT ${savepoint}`, - ) - if (error instanceof QueryFailedError) { - log.error( - `[ChainDB] [ ERROR ]: Failed to insert transaction ${tx.hash}. Skipping it ...`, - ) - log.error(`Message: ${error.message}`) - continue - } - - log.error( - "Unexpected error while inserting tx: " + tx.hash, - ) - handleError(error, "CHAIN", { - source: "transaction insertion", - }) - throw error } } + const insertTransactionsEnd = Date.now() + log.only( + `[insertBlock] Insert transactions took ${insertTransactionsEnd - insertTransactionsStart}ms`, + ) + + const removeTransactionsStart = Date.now() if (cleanMempool) { await Mempool.removeTransactionsByHashes( transactionEntities.map(tx => tx.hash), transactionalEntityManager, ) } + const removeTransactionsEnd = Date.now() + log.only( + `[insertBlock] Remove transactions took ${removeTransactionsEnd - removeTransactionsStart}ms`, + ) + const commitmentsStart = Date.now() const committedTxHashes = transactionEntities.map(tx => tx.hash) if (committedTxHashes.length > 0) { await transactionalEntityManager @@ -351,7 +415,12 @@ export async function insertBlock( }) .execute() } + const commitmentsEnd = Date.now() + log.only( + `[insertBlock] Commitments took ${commitmentsEnd - commitmentsStart}ms`, + ) + const updateMerkleTreeStart = Date.now() const commitmentsAdded = await updateMerkleTreeAfterBlock( dataSource, block.number, @@ -362,6 +431,10 @@ export async function insertBlock( `[ZK] Added ${commitmentsAdded} commitment(s) to Merkle tree for block ${block.number}`, ) } + const updateMerkleTreeEnd = Date.now() + log.only( + `[insertBlock] Update Merkle tree took ${updateMerkleTreeEnd - updateMerkleTreeStart}ms`, + ) // Governance hooks scoped to the block transaction so they // commit/rollback atomically with it. sharedState refresh @@ -382,11 +455,6 @@ export async function insertBlock( }, ) - if (block.number > getSharedState.lastBlockNumber) { - getSharedState.lastBlockNumber = block.number - getSharedState.lastBlockHash = block.hash - } - log.debug( "[insertBlock] lastBlockNumber: " + getSharedState.lastBlockNumber, ) diff --git a/src/libs/blockchain/chainDb.ts b/src/libs/blockchain/chainDb.ts index b68f36541..add8d21f8 100644 --- a/src/libs/blockchain/chainDb.ts +++ b/src/libs/blockchain/chainDb.ts @@ -1,4 +1,4 @@ -import { Repository } from "typeorm" +import { EntityManager, EntityTarget, ObjectLiteral, Repository } from "typeorm" import log from "src/utilities/logger" import Datasource from "src/model/datasource" import { Blocks } from "src/model/entities/Blocks" @@ -40,3 +40,57 @@ export async function writeSql(sqlQuery: string) { throw err } } + +// Postgres caps bind parameters at 65535 (uint16). Chunk row counts so +// rows * inserted-column-count stays under PG_BIND_BUDGET. If you add a +// column to one of these entities, update its divisor. +const PG_BIND_BUDGET = 65000 +export const CHUNK_TRANSACTIONS = Math.floor(PG_BIND_BUDGET / 16) // Transactions: 16 inserted cols +export const CHUNK_MEMPOOL_TX = Math.floor(PG_BIND_BUDGET / 10) // MempoolTx: 10 inserted cols +export const CHUNK_ASSIGNED_TXS = Math.floor(PG_BIND_BUDGET / 10) // GCRAssignedTx: 3 inserted cols + +export interface ChunkedInsertResult { + inserted: number + skipped: number +} + +/** + * Bulk INSERT ... ON CONFLICT DO NOTHING in chunks that stay under the + * Postgres 65,535 bind-parameter wire-protocol limit. + */ +export async function chunkedInsert( + runner: EntityManager | Repository, + target: EntityTarget, + rows: any[], + chunkSize: number, + orUpdate?: { + conflictTarget?: string | string[] + overwrite: string[] + }, +): Promise { + let inserted = 0 + let skipped = 0 + for (let i = 0; i < rows.length; i += chunkSize) { + const chunk = rows.slice(i, i + chunkSize) + const query = runner + .createQueryBuilder() + .insert() + .into(target) + .values(chunk) + + if (orUpdate) { + query.orUpdate(orUpdate.overwrite, orUpdate.conflictTarget) + } else { + query.orIgnore() + } + + const result = await query.execute() + const chunkInserted = result.identifiers.filter( + id => id !== undefined, + ).length + inserted += chunkInserted + skipped += chunk.length - chunkInserted + } + + return { inserted, skipped } +} diff --git a/src/libs/blockchain/chainGenesis.ts b/src/libs/blockchain/chainGenesis.ts index 5de2ecd56..20d5a681a 100644 --- a/src/libs/blockchain/chainGenesis.ts +++ b/src/libs/blockchain/chainGenesis.ts @@ -104,7 +104,7 @@ export async function generateGenesisBlock(genesisData: any): Promise { log.debug("[GENESIS] Block generated, ready to insert it") log.debug("[GENESIS] inserting transaction into the mempool") - await Mempool.addTransaction({ ...genesisTx, reference_block: 0 }) + await Mempool.addTransaction({ ...genesisTx, reference_block: 0 }, 0) log.debug("[GENESIS] inserted transaction") // SECTION: Restoring account data @@ -220,13 +220,3 @@ export async function generateGenesisBlock(genesisData: any): Promise { await insertBlock(genesisBlock, [genesisOp], 0) return genesisBlock } - -export async function generateGenesisBlocks(genesisJsons: any[]): Promise { - const compiledBlock = "" - // TODO - return compiledBlock -} - -export async function getGenesisUniqueBlock() { - // TODO -} diff --git a/src/libs/blockchain/chainStatus.ts b/src/libs/blockchain/chainStatus.ts deleted file mode 100644 index 3f34f1010..000000000 --- a/src/libs/blockchain/chainStatus.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ILike } from "typeorm" -import Datasource from "src/model/datasource" -import { GCRHashes } from "src/model/entities/GCRv2/GCRHashes" -import { GCRExtended } from "src/model/entities/GCR/GlobalChangeRegistry" -import { GlobalChangeRegistry } from "src/model/entities/GCR/GlobalChangeRegistry" - -export async function statusOf( - address: string, - type: number, -): Promise { - if (type === 0) { - const db = await Datasource.getInstance() - const gcrRepository = db - .getDataSource() - .getRepository(GlobalChangeRegistry) - - return (await gcrRepository.findOneBy({ - publicKey: ILike(address), - })) as GlobalChangeRegistry - } else if (type === 1) { - const db = await Datasource.getInstance() - const gcrRepository = db - .getDataSource() - .getRepository(GlobalChangeRegistry) - - return (await gcrRepository.findOneBy({ - publicKey: ILike(address), - })) as GlobalChangeRegistry - } - return null -} - -export async function statusHashAt(blockNumber: number) { - const db = await Datasource.getInstance() - const gcrHashesRepository = db.getDataSource().getRepository(GCRHashes) - - const gcrHashesSearch = await gcrHashesRepository.findOneBy({ - block: blockNumber, - }) - return gcrHashesSearch ? gcrHashesSearch.hash : null -} diff --git a/src/libs/blockchain/chainTransactions.ts b/src/libs/blockchain/chainTransactions.ts index b5305a510..f2ad83102 100644 --- a/src/libs/blockchain/chainTransactions.ts +++ b/src/libs/blockchain/chainTransactions.ts @@ -1,13 +1,17 @@ -import { ILike, In, LessThan, EntityManager, FindManyOptions } from "typeorm" +import { In, LessThan, EntityManager, FindManyOptions } from "typeorm" import log from "src/utilities/logger" import Transaction, { toTransactionsEntity } from "./transaction" import { Transactions } from "src/model/entities/Transactions" import { L2PSHash } from "src/model/entities/L2PSHashes" -import { handleError } from "src/errors" -import { getTransactionsRepo } from "./chainDb" -import Mempool from "./mempool" +import { + CHUNK_TRANSACTIONS, + chunkedInsert, + getTransactionsRepo, +} from "./chainDb" import type { TransactionContent } from "@kynesyslabs/demosdk/types" import type { L2PSHashUpdatePayload, TxStatus } from "./chainTypes" +import Mempool from "./mempool" +import { getSharedState } from "@/utilities/sharedState" export function getL2PSHashUpdatePayload( tx: Transaction, @@ -55,15 +59,26 @@ export async function persistConfirmedTransactionProjection( export async function getTxByHash(hash: string): Promise { try { + const getTxByHashStart = Date.now() const rawTx = await getTransactionsRepo().findOneBy({ - hash: ILike(hash), + hash: hash, }) if (!rawTx) { return null } - return Transaction.fromRawTransaction(rawTx) + const convertToTransactionStart = Date.now() + const tx = Transaction.fromRawTransaction(rawTx) + const convertToTransactionEnd = Date.now() + log.only( + `[getTxByHash] Convert to transaction took ${convertToTransactionEnd - convertToTransactionStart}ms`, + ) + const getTxByHashEnd = Date.now() + log.only( + `[getTxByHash] Get tx by hash took ${getTxByHashEnd - getTxByHashStart}ms`, + ) + return tx } catch (error) { log.error(`[ChainDB] [ ERROR ]: ${JSON.stringify(error)}`) throw error @@ -122,14 +137,47 @@ export async function getBlockTransactions( blockHash: string, ): Promise { const { getBlockByHash } = await import("./chainBlocks") - const block = await getBlockByHash(blockHash) - return await getTransactionsFromHashes(block.content.ordered_transactions) + let block = await getBlockByHash(blockHash) + + if (!block) { + if (blockHash === getSharedState.candidateBlock.hash) { + block = getSharedState.candidateBlock + } else { + return [] + } + } + + const toGet = new Set(block.content.ordered_transactions) + + const fetched = await getTransactionsFromHashes( + block.content.ordered_transactions, + ) + let missing: Transaction[] = [] + + for (const tx of fetched) { + toGet.delete(tx.hash) + } + + if (toGet.size > 0 && getSharedState.lastBlockNumber - block.number <= 1) { + // NOTE: If peer tries to fetch transactions for a block not + // finalized by this peer yet, + // (because block is broadcasted right after voting, + // and it's possible that this peer might be slow) + // the block txs will not be in the transactions table yet, + // fetch them from the mempool, and update the blockNumber to match block + missing = await Mempool.getTransactionsByHashes(Array.from(toGet)) + } + + return [ + ...fetched, + ...missing.map(tx => ({ ...tx, blockNumber: block.number })), + ] } export async function getTransactionFromHash( hash: string, ): Promise { - const rawTx = await getTransactionsRepo().findOneBy({ hash: ILike(hash) }) + const rawTx = await getTransactionsRepo().findOneBy({ hash: hash }) if (!rawTx) { return null } @@ -188,9 +236,7 @@ export async function insertTransaction( transaction: Transaction, status = "confirmed", ): Promise { - log.debug( - "[insertTransaction] Inserting transaction: " + transaction.hash, - ) + log.debug("[insertTransaction] Inserting transaction: " + transaction.hash) const rawTransaction = Transaction.toRawTransaction(transaction, status) log.debug("[insertTransaction] Raw transaction: ") log.debug(JSON.stringify(rawTransaction)) @@ -213,13 +259,36 @@ export async function insertTransaction( export async function insertTransactionsFromSync( transactions: Transaction[], ): Promise { - for (const tx of transactions) { - try { - await insertTransaction(tx) - } catch (error) { - handleError(error, "CHAIN", { source: "ChainDB sync insertion" }) - } - } + if (transactions.length === 0) return true + + const datasourceModule = (await import("src/model/datasource")).default + + const db = await datasourceModule.getInstance() + const dataSource = db.getDataSource() + + try { + await dataSource.transaction(async em => { + const rawTransactions = transactions.map(tx => + Transaction.toRawTransaction(tx, "confirmed"), + ) + const { skipped } = await chunkedInsert( + em, + Transactions, + rawTransactions as any[], + CHUNK_TRANSACTIONS, + ) + if (skipped > 0) { + log.warn( + `[insertTransactionsFromSync] Skipped ${skipped} duplicate transaction(s)`, + ) + } + }) - return true + return true + } catch (error) { + log.error( + `[insertTransactionsFromSync] Transaction batch failed: ${error}`, + ) + return false + } } diff --git a/src/libs/blockchain/gcr/gcr.ts b/src/libs/blockchain/gcr/gcr.ts index 348f75f1b..6aada667c 100644 --- a/src/libs/blockchain/gcr/gcr.ts +++ b/src/libs/blockchain/gcr/gcr.ts @@ -45,63 +45,29 @@ KyneSys Labs: https://www.kynesys.xyz/ // TODO insert it in the gcr automatically so that the parameters of the // TODO chain are both immutable and editable at the same time -import * as fs from "fs" -import Hashing from "src/libs/crypto/hashing" -import Datasource from "src/model/datasource" -import { GlobalChangeRegistry } from "src/model/entities/GCR/GlobalChangeRegistry" -import { GCRExtended } from "src/model/entities/GCR/GlobalChangeRegistry" -import { Validators } from "src/model/entities/Validators" +import _ from "lodash" import { In, LessThan, LessThanOrEqual, Not } from "typeorm" -import { - Operation, - OperationRegistrySlot, - OperationResult, - RPCResponse, -} from "@kynesyslabs/demosdk/types" +import Hashing from "src/libs/crypto/hashing" +import Datasource from "src/model/datasource" +import { RPCResponse } from "@kynesyslabs/demosdk/types" import Chain from "../chain" -import executeOperations, { Actor } from "../routines/executeOperations" -import gcrStateSave from "./gcr_routines/gcrStateSaverHelper" import { GCRMain } from "@/model/entities/GCRv2/GCR_Main" -import { Referrals } from "@/features/incentive/referrals" import log from "@/utilities/logger" import { skeletons } from "@kynesyslabs/demosdk/websdk" import { getSharedState } from "@/utilities/sharedState" -import { ucrypto, uint8ArrayToHex } from "@kynesyslabs/demosdk/encryption" +import { uint8ArrayToHex } from "@kynesyslabs/demosdk/encryption" import HandleGCR from "./handleGCR" import Mempool from "../mempool" import { serializeTransactionContent } from "@/forks" +import TxValidatorPool from "../validation/txValidatorPool" +import { GCRSubnetsTxs } from "@/model/entities/GCRv2/GCRSubnetsTxs" +import { emptyResponse } from "@/libs/network" +import { Validators } from "@/model/entities/Validators" -// ? This class should be deprecated: ensure that and remove it -export class OperationsRegistry { - path = "data/operations.json" - operations: OperationRegistrySlot[] = [] - - constructor() { - // Creating an empty registry if it doesn't exist - if (!fs.existsSync(this.path)) fs.writeFileSync(this.path, "[]") - this.operations = JSON.parse(fs.readFileSync(this.path).toString()) - } - - // INFO Adding an operation to the registry - async add(operation: Operation) { - this.operations.push({ - operation: operation, - status: "pending", - result: { - success: false, - message: "ot yet processed", - }, - timestamp: Date.now(), - }) - await fs.promises.writeFile(this.path, JSON.stringify(this.operations)) - } - - // INFO Getting the full list of operations currently in the registry - get(): OperationRegistrySlot[] { - return this.operations - } +export type GetNativeSubnetsTxsOptions = { + txData?: boolean } interface Web2AccountParams { @@ -145,165 +111,36 @@ function isNativeAccount( return "address" in account && !("chain" in account) } -// INFO Besides the static methods, the GCR store all the operations to be done in the current block so that they can be executed in order export default class GCR { - private static instance: GCR - operations: Operation[] // TODO It will become the above implementation - - private constructor() { - this.operations = [] - } - - // Singleton logic - static getInstance(): GCR { - if (!this.instance) { - this.instance = new GCR() - } - return this.instance - } - - // NOTE Due to the complexity of this method, it is imported by the appropriate module - // INFO Any type of transaction is already converted as a native DEMOS transaction - // so that the appropriate Operatin can be executed - async executeOperations(): Promise> { - const result = await executeOperations(this.operations) - return result - } - - static async getGCRStatusNativeTable() { - const db = await Datasource.getInstance() - const gcrRepository = db - .getDataSource() - .getRepository(GlobalChangeRegistry) - return await gcrRepository.find() - } - - static async getGCRStatusPropertiesTable(publicKey: string) { - const db = await Datasource.getInstance() - const gcrRepository = db - .getDataSource() - .getRepository(GlobalChangeRegistry) - const gcrSearch = await gcrRepository.findOneBy({ publicKey }) - const gcrExtendedData = gcrSearch?.extended - return gcrExtendedData - } - - static async getGCRNativeFor(address: string) { - const db = await Datasource.getInstance() - const gcrRepository = db - .getDataSource() - .getRepository(GlobalChangeRegistry) - return await gcrRepository.findOne({ - where: { publicKey: address }, - }) - } - - static async getGCRPropertiesFor( - address: string, - field: keyof GCRExtended, - ) { - const db = await Datasource.getInstance() - const gcrRepository = db - .getDataSource() - .getRepository(GlobalChangeRegistry) - const gcrSearch = await gcrRepository.findOneBy({ - publicKey: address, - }) - const gcrExtendedData = gcrSearch?.extended - return gcrExtendedData[field] - } - // ANCHOR Balances retrieval - // REVIEW Returns bigint to keep the native-balance arithmetic chain - // bigint-consistent (subOperations.transferNative, addNative, removeNative - // all expect BigInt-safe values). Legacy JSONB storage still holds the - // value as a JS number, so we coerce on read. - static async getGCRNativeBalance(address: string): Promise { - const db = await Datasource.getInstance() - const gcrRepository = db - .getDataSource() - .getRepository(GlobalChangeRegistry) - - try { - const response = await gcrRepository.findOne({ - select: ["details"], - where: { publicKey: address }, - }) - const stored = response ? response.details.content.balance : 0 - return BigInt(stored ?? 0) - } catch (e) { - log.debug(`[GET BALANCE] No balance for: ${address}`) - return 0n - } - } - - static async getGCRTokenBalance(address: string, tokenAddress: string) { + /** + * + * @param pubkey Get the balance of a GCR account + * @returns The balance of the account + */ + static async getAccountBalance(pubkey: string): Promise { const db = await Datasource.getInstance() - const gcrRepository = db - .getDataSource() - .getRepository(GlobalChangeRegistry) + const gcrRepository = db.getDataSource().getRepository(GCRMain) try { - const gcrSearch = await gcrRepository.findOneBy({ - publicKey: address, + const account = await gcrRepository.findOne({ + where: { pubkey }, + select: ["balance"], }) - const gcrExtendedData = gcrSearch?.extended - return gcrExtendedData && gcrExtendedData.tokens - ? gcrExtendedData.tokens[tokenAddress] - : 0 - } catch (e) { - log.error(`[GCR] Error fetching GCR token balance: ${e}`) - } - } - - static async getGCRNFTBalance(address: string, nftAddress: string) { - const db = await Datasource.getInstance() - const gcrRepository = db - .getDataSource() - .getRepository(GlobalChangeRegistry) - try { - const gcrSearch = await gcrRepository.findOneBy({ - publicKey: address, - }) - const gcrExtendedData = gcrSearch?.extended - return gcrExtendedData && gcrExtendedData.nfts - ? gcrExtendedData.nfts[nftAddress] - : 0 + return account ? BigInt(account.balance) : 0n } catch (e) { - log.error(`[GCR] Error fetching GCR NFT balance: ${e}`) + log.debug(`[GET BALANCE] No balance for: ${pubkey}`) + return 0n } } static async getGCRLastBlockBaseGas(): Promise { // TODO Implement and make it dynamic - /* let chainProperties = await GCR.getGCRChainProperties() - return chainProperties.gas_multiplier */ return 1 } - // INFO In the GCR properties table, the special row "DEMOS Network" defines, in the other - // field, the properties of the chain itself shared by all its members. - // TODO Maybe implement it at genesis or retrieve the genesis from chain? - static async getGCRChainProperties(): Promise { - const db = await Datasource.getInstance() - const gcrRepository = db - .getDataSource() - .getRepository(GlobalChangeRegistry) - - try { - const gcrSearch = await gcrRepository.findOneBy({ - publicKey: "DEMOS Network", - }) - const gcrExtendedData = gcrSearch?.extended - return gcrExtendedData && gcrExtendedData.other - } catch (e) { - // Handle the error appropriately - log.error(`Error fetching GCR chain properties: ${e}`) - } - } - // SECTION Validators management // INFO The following getter is used to retrieve the hashed form of the sum of all the stakes at block N @@ -373,7 +210,7 @@ export default class GCR { } } - // INFO Get a validator (or a public key anyway) status in the staking +// INFO Get a validator (or a public key anyway) status in the staking // NOTE While accepting a blockNumber, it defaults to the last one static async getGCRValidatorStatus( publicKeyHex: string, @@ -403,204 +240,37 @@ export default class GCR { } } - // !SECTION Validators management - - // SECTION Setters - // NOTE For consistency, setters should return a Promise - - // INFO Assigning a XM Transaction to an address - static async addToGCRXM( - address: string, - xmHash: string, - ): Promise { - const result: OperationResult = { - success: false, - message: "", - } - try { - let statusProperties: GCRExtended - // Getting the table - const db = await Datasource.getInstance() - const gcrRepository = db - .getDataSource() - .getRepository(GlobalChangeRegistry) - const gcrSearch = await gcrRepository.findOneBy({ - publicKey: address, - }) - statusProperties = gcrSearch?.extended - // Or creating it if it doesn't exist - if (!statusProperties) { - statusProperties = { - tokens: [], - nfts: [], - xm: [], - web2: [], - other: [], - } - } - // Loading the object - const jStatusProperties = statusProperties.xm - jStatusProperties.push(xmHash) - // And updating it - statusProperties.xm = jStatusProperties - await gcrRepository.update( - { publicKey: address }, - { extended: statusProperties }, - ) - // REVIEW Save the hash of the GCR for this public key - await gcrStateSave.updateGCRTracker(address) - result.success = true - } catch (e) { - result.message = JSON.stringify(e) - } - return result - } - - // INFO Assigning a Web2 Transaction to an address - static async addToGCRWeb2( - address: string, - web2Hash: string, - ): Promise { - const result: OperationResult = { - success: false, - message: "", - } - try { - let statusProperties: any - // Getting the table - const db = await Datasource.getInstance() - const gcrRepository = db - .getDataSource() - .getRepository(GlobalChangeRegistry) - const gcrSearch = await gcrRepository.findOneBy({ - publicKey: address, - }) - statusProperties = gcrSearch?.extended - // Or creating it if it doesn't exist - if (!statusProperties) { - statusProperties = { - tokens: [], - nfts: [], - xm: [], - web2: [], - other: [], - } - } - // Loading the object - const jStatusProperties = JSON.parse(statusProperties.web2) - jStatusProperties.push(web2Hash) - // And updating it - statusProperties.web2 = jStatusProperties - await gcrRepository.update( - { publicKey: address }, - { extended: statusProperties }, - ) - // REVIEW Save the hash of the GCR for this public key - await gcrStateSave.updateGCRTracker(address) - result.success = true - } catch (e) { - result.success = false - result.message = JSON.stringify(e) - } - return result - } - - // INFO Assigning a IMPData hash to an address or to the L1 itself - static async addToGCRIMPData( - address: string, - impDataHash: string, - ): Promise { - const result: OperationResult = { - success: false, - message: "", - } - // TODO Add stuff after loading the IMPData - if (address === "demos") { - // TODO Assigning to the blockchain - } - return result - } - - // REVIEW Accepts bigint to keep the native-balance arithmetic chain - // bigint-consistent. The legacy JSONB column stores the value as a JS - // number, so we coerce here. Values exceeding Number.MAX_SAFE_INTEGER are - // rejected to make the precision-loss failure mode loud instead of silent. - static async setGCRNativeBalance( - address: string, - native: bigint | number, - txHash: string, - ): Promise { + static async getNativeSubnetsTxs( + subnetId: string, + options: GetNativeSubnetsTxsOptions = { + txData: true, + }, + ): Promise { + const response: RPCResponse = _.cloneDeep(emptyResponse) const db = await Datasource.getInstance() - const gcrRepository = db + const gcrSubnetsTxsRepository = db .getDataSource() - .getRepository(GlobalChangeRegistry) - - try { - const nativeBigInt = BigInt(native) - if ( - nativeBigInt > BigInt(Number.MAX_SAFE_INTEGER) || - nativeBigInt < BigInt(Number.MIN_SAFE_INTEGER) - ) { - log.error( - `[GCR ERROR: NATIVE] Native balance ${nativeBigInt} for ${address} exceeds Number.MAX_SAFE_INTEGER; refusing to truncate into legacy JSONB storage`, - ) - return false - } - const nativeNumber = Number(nativeBigInt) - - let nativeStatus = await gcrRepository.findOne({ - select: ["details"], - where: { publicKey: address }, - }) - - if (!nativeStatus) { - log.debug("Creating new native status") - nativeStatus = gcrRepository.create({ - publicKey: address, - details: { - hash: "", - content: { - balance: 0, - identities: { - xm: {}, - web2: {}, - }, - txs: [], - nonce: 0, - }, - }, - }) - await gcrRepository.save(nativeStatus) + .getRepository(GCRSubnetsTxs) + // Getting the status subnets txs data + const gcrSubnetsTxsSearch = await gcrSubnetsTxsRepository.findBy({ + subnet_id: subnetId, + }) + if (!gcrSubnetsTxsSearch) { + response.response = "Subnet not found" + response.result = 404 + return response + } + // Preparing the response + const gcrSubnetsTxsData: GCRSubnetsTxs[] = [] + // Selecting only the requested data + if (!options.txData) { + for (const tx of gcrSubnetsTxsSearch) { + tx.tx_data = null + gcrSubnetsTxsData.push(tx) } - - //console.log(nativeStatus.details.txs) - const txList = nativeStatus.details.content.txs || [] - txList.push(txHash) - - await gcrRepository.update( - { publicKey: address }, - { - details: { - hash: "", - content: { - balance: nativeNumber, - txs: txList, - nonce: nativeStatus.details.content.nonce, - }, - }, - }, - ) - - //console.log(tx_list) - // TODO: Decide if we should use status_hashes too - // Note: The original function returns responses from Chain.write, consider what you need to return here. - return true // Adjust the return value as needed based on your requirements. - } catch (e) { - log.error( - "[GCR ERROR: NATIVE] Error setting GCR native balance: " + e, - ) - return false } + response.response = gcrSubnetsTxsData + return response } static async getAccountByTwitterUsername(username: string) { @@ -1159,7 +829,7 @@ export default class GCR { serializeTransactionContent(tx.content, referenceHeight), ) - const signature = await ucrypto.sign( + const signature = await TxValidatorPool.getInstance().sign( getSharedState.signingAlgorithm, new TextEncoder().encode(tx.hash), ) diff --git a/src/libs/blockchain/gcr/gcr_routines/applyGCROperation.ts b/src/libs/blockchain/gcr/gcr_routines/applyGCROperation.ts deleted file mode 100644 index d8dd7127e..000000000 --- a/src/libs/blockchain/gcr/gcr_routines/applyGCROperation.ts +++ /dev/null @@ -1,35 +0,0 @@ -import GCROperation from "src/libs/blockchain/gcr/types/GCROperations" -import { EntityTarget, Repository, FindOptionsOrder } from "typeorm" -import Datasource from "../../../../model/datasource" -import Hashing from "src/libs/crypto/hashing" -import { GCRSubnetsTxs } from "../../../../model/entities/GCRv2/GCRSubnetsTxs" -import { GlobalChangeRegistry } from "../../../../model/entities/GCR/GlobalChangeRegistry" -import { GCRHashes } from "../../../../model/entities/GCRv2/GCRHashes" -import { GCRTracker } from "src/model/entities/GCR/GCRTracker" - -// TODO Call the GCR methods to apply the operation to the GCR tables -// TODO See if we can have a diff of the DB tables and apply only the changes - -export default async function applyGCROperation( - operation: GCROperation, -): Promise { - // Get the GCR tables - const success = true - const db = await Datasource.getInstance() - const gcrTrackerRepository: Repository = db - .getDataSource() - .getRepository(GCRTracker) - const gcrHashesRepository: Repository = db - .getDataSource() - .getRepository(GCRHashes) - const gcrSubnetsTxsRepository: Repository = db - .getDataSource() - .getRepository(GCRSubnetsTxs) - const globalChangeRegistryRepository: Repository = db - .getDataSource() - .getRepository(GlobalChangeRegistry) - // TODO Examine the operation and apply it to the GCR tables - // TODO Update the GCR hashes - // ? Is there a way to return a diff of the GCR tables? - return success -} diff --git a/src/libs/blockchain/gcr/gcr_routines/assignWeb2.ts b/src/libs/blockchain/gcr/gcr_routines/assignWeb2.ts deleted file mode 100644 index 6f24fb838..000000000 --- a/src/libs/blockchain/gcr/gcr_routines/assignWeb2.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Operation, OperationResult } from "@kynesyslabs/demosdk/types" - -import GCR from "../gcr" -export async function assignWeb2( - operation: Operation, -): Promise { - const { address, web2Hash } = operation.params - return await GCR.addToGCRWeb2(address, web2Hash) -} diff --git a/src/libs/blockchain/gcr/gcr_routines/assignXM.ts b/src/libs/blockchain/gcr/gcr_routines/assignXM.ts deleted file mode 100644 index ec78a6433..000000000 --- a/src/libs/blockchain/gcr/gcr_routines/assignXM.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Operation, OperationResult } from "@kynesyslabs/demosdk/types" - -import GCR from "../gcr" - -export async function assignXM(operation: Operation): Promise { - const { address, xmHash } = operation.params - return await GCR.addToGCRXM(address, xmHash) -} diff --git a/src/libs/blockchain/gcr/gcr_routines/gcrJSONBHandler.ts b/src/libs/blockchain/gcr/gcr_routines/gcrJSONBHandler.ts index 7ad2899df..6e106d3dc 100644 --- a/src/libs/blockchain/gcr/gcr_routines/gcrJSONBHandler.ts +++ b/src/libs/blockchain/gcr/gcr_routines/gcrJSONBHandler.ts @@ -25,7 +25,7 @@ import { GCRMain } from "@/model/entities/GCRv2/GCR_Main" */ export async function getJSONBValue( pubkey: string, - field: "identities" | "assignedTxs", + field: "identities", key: string, subkey?: string, ) { @@ -71,7 +71,7 @@ await GCRJsonbHandler.updateJSONBValue( */ export async function updateJSONBValue( pubkey: string, - field: "assignedTxs" | "identities", + field: "identities", key: string, value: any, subkey?: string, diff --git a/src/libs/blockchain/gcr/gcr_routines/gcrStateSaverHelper.ts b/src/libs/blockchain/gcr/gcr_routines/gcrStateSaverHelper.ts deleted file mode 100644 index 8bd539595..000000000 --- a/src/libs/blockchain/gcr/gcr_routines/gcrStateSaverHelper.ts +++ /dev/null @@ -1,52 +0,0 @@ -// INFO At the end of each block forging (aka at the end of the consensus mechanism), each node operator will be able to get this GCR state hash -import { Hash } from "crypto" -import Hashing from "src/libs/crypto/hashing" -import Datasource from "src/model/datasource" -import { GCRHashes } from "src/model/entities/GCRv2/GCRHashes" - -// which is set by the validators during the consensus and is an hash of all the applicable operations for that block. -import Chain from "../../chain" -import { Operation } from "@kynesyslabs/demosdk/types" -import { GCRTracker } from "src/model/entities/GCR/GCRTracker" -import { GlobalChangeRegistry } from "src/model/entities/GCR/GlobalChangeRegistry" - -// REVIEW This could be avoided probably, by using inline hashes instead of hashing the whole table -// TODO Expand the operation registry (if any) to support inlining of hashes into GCR states. - -// INFO Take the ordered list of operations from the consensus mechanism and hash it -export default class GCRStateSaverHelper { - constructor() {} - - static getLastConsenusStateHash() { - // TODO Get the last consensus state hash from the database (see the below methods) - } - - // Updating the GCR tracker for a given public key - static async updateGCRTracker(publicKey: string) { - // TODO Update the GCR tracker for a given public key - const db = await Datasource.getInstance() - const gcrTrackerRepository = db - .getDataSource() - .getRepository(GCRTracker) - const gcrRepository = db - .getDataSource() - .getRepository(GlobalChangeRegistry) - const userData = await gcrRepository.findOne({ - where: { publicKey: publicKey }, - }) - if (!userData) { - throw new Error("User data not found") - } - const hash = Hashing.sha256(JSON.stringify(userData)) - // Creating or updating the GCR tracker using upsert - await gcrTrackerRepository.upsert( - { - publicKey: publicKey, - hash: hash, - }, - { - conflictPaths: ["publicKey"], - }, - ) - } -} diff --git a/src/libs/blockchain/gcr/gcr_routines/hashGCR.ts b/src/libs/blockchain/gcr/gcr_routines/hashGCR.ts index 0638de872..862c2f7cb 100644 --- a/src/libs/blockchain/gcr/gcr_routines/hashGCR.ts +++ b/src/libs/blockchain/gcr/gcr_routines/hashGCR.ts @@ -3,10 +3,8 @@ import Datasource from "../../../../model/datasource" import Hashing from "src/libs/crypto/hashing" import { GCRSubnetsTxs } from "../../../../model/entities/GCRv2/GCRSubnetsTxs" import { GCRTLSNotary } from "../../../../model/entities/GCRv2/GCR_TLSNotary" -import { GlobalChangeRegistry } from "../../../../model/entities/GCR/GlobalChangeRegistry" import { GCRHashes } from "../../../../model/entities/GCRv2/GCRHashes" import Chain from "src/libs/blockchain/chain" -import { GCRTracker } from "src/model/entities/GCR/GCRTracker" import { type NativeTablesHashes } from "@kynesyslabs/demosdk/types" /** @@ -103,12 +101,10 @@ export async function hashTLSNotaryTable(): Promise { export default async function hashGCRTables(): Promise { // Get all individual hashes // REVIEW: The below was GCRTracker without "", which was causing an error as is not an entity - const gcrHash = await hashPublicKeyTable("gcr_tracker") // Tracking the GCR hashes as they are hashes of the GCR itself const subnetsTxsHash = await hashSubnetsTxsTable() // REVIEW: TLSNotary proofs included in GCR integrity hash const tlsnotaryHash = await hashTLSNotaryTable() return { - native_gcr: gcrHash, native_subnets_txs: subnetsTxsHash, native_tlsnotary: tlsnotaryHash, } diff --git a/src/libs/blockchain/gcr/gcr_routines/identityManager.ts b/src/libs/blockchain/gcr/gcr_routines/identityManager.ts index f7301a05f..95ddd1562 100644 --- a/src/libs/blockchain/gcr/gcr_routines/identityManager.ts +++ b/src/libs/blockchain/gcr/gcr_routines/identityManager.ts @@ -25,6 +25,7 @@ import { chainIds } from "sdk/localsdk/multichain/configs/chainIds" import { NomisWalletIdentity, SavedHumanPassportIdentity, EthosWalletIdentity } from "@/model/entities/types/IdentityTypes" import { HumanPassportProvider } from "@/libs/identity/tools/humanpassport" import { verifyMessage as verifyEvmMessage } from "ethers" +import TxValidatorPool from "../../validation/txValidatorPool" function normalizeComparableAddress(address: string | undefined | null): string { return (address || "").trim().toLowerCase() @@ -276,7 +277,7 @@ export default class IdentityManager { senderEd25519: string, ): Promise<{ success: boolean; message: string }> { for (const payload of payloads) { - const verified = await ucrypto.verify({ + const verified = await TxValidatorPool.getInstance().verify({ algorithm: "ed25519", signature: hexToUint8Array(payload.signature), publicKey: hexToUint8Array(senderEd25519), diff --git a/src/libs/blockchain/gcr/gcr_routines/index.ts b/src/libs/blockchain/gcr/gcr_routines/index.ts index 06b0c2167..895cd32f9 100644 --- a/src/libs/blockchain/gcr/gcr_routines/index.ts +++ b/src/libs/blockchain/gcr/gcr_routines/index.ts @@ -1,12 +1,6 @@ -import { assignWeb2 } from "./assignWeb2" -import { assignXM } from "./assignXM" import hashGCRTables from "./hashGCR" -// import native from "./manageNative" const gcrRoutines = { - assignWeb2: assignWeb2, - assignXM: assignXM, - // native: native, hashGCRTables: hashGCRTables, } diff --git a/src/libs/blockchain/gcr/handleGCR.ts b/src/libs/blockchain/gcr/handleGCR.ts index 2a50e2d3a..604b93b33 100644 --- a/src/libs/blockchain/gcr/handleGCR.ts +++ b/src/libs/blockchain/gcr/handleGCR.ts @@ -19,27 +19,10 @@ * - Also implement the new GCROperation structure replacing the old Operation one */ -import { emptyResponse } from "./../../network/server_rpc" import _ from "lodash" -// NOTE This will replace gcr.ts methods for calling the native tables -import { GCRSubnetsTxs } from "src/model/entities/GCRv2/GCRSubnetsTxs" // TODO Put this in the sdk when done -import { GCRHashes } from "src/model/entities/GCRv2/GCRHashes" -import { - RPCResponse, - Transaction, - TransactionContent, -} from "@kynesyslabs/demosdk/types" + +import { Transaction, TransactionContent } from "@kynesyslabs/demosdk/types" import Datasource, { dataSource } from "src/model/datasource" -import { GlobalChangeRegistry } from "src/model/entities/GCR/GlobalChangeRegistry" -import { GCRExtended } from "src/model/entities/GCR/GlobalChangeRegistry" -import hashGCRTables from "./gcr_routines/hashGCR" -import * as GCRJsonbHandler from "./gcr_routines/gcrJSONBHandler" -import ensureGCRForUser from "./gcr_routines/ensureGCRForUser" -import gcrStateSave from "./gcr_routines/gcrStateSaverHelper" -import { assignXM } from "./gcr_routines/assignXM" -import { assignWeb2 } from "./gcr_routines/assignWeb2" -import IdentityManager from "./gcr_routines/identityManager" -import manageNative from "./gcr_routines/manageNative" import { GCREdit, GCREditStorageProgram } from "@kynesyslabs/demosdk/types" import { GCREditBalance, @@ -52,13 +35,13 @@ import { forgeToHex } from "@/libs/crypto/forgeUtils" // REVIEW Trying to use the new GCRv2 import { GCRMain } from "src/model/entities/GCRv2/GCR_Main" -import { GCRTracker } from "src/model/entities/GCR/GCRTracker" +import { GCRAssignedTx } from "src/model/entities/GCRv2/GCRAssignedTx" +import { CHUNK_ASSIGNED_TXS } from "src/libs/blockchain/chainDb" import GCRBalanceRoutines from "./gcr_routines/GCRBalanceRoutines" import GCRNonceRoutines from "./gcr_routines/GCRNonceRoutines" import GCRValidatorStakeRoutines from "./gcr_routines/GCRValidatorStakeRoutines" -import Chain from "../chain" -import { In, Repository } from "typeorm" +import { In } from "typeorm" import { Mutex } from "async-mutex" import GCRIdentityRoutines from "./gcr_routines/GCRIdentityRoutines" import { GCRTLSNotaryRoutines } from "./gcr_routines/GCRTLSNotaryRoutines" @@ -86,10 +69,6 @@ export type GetNativePropertiesOptions = { other?: boolean } -export type GetNativeSubnetsTxsOptions = { - txData?: boolean -} - export interface GCRResult { success: boolean message: string @@ -118,6 +97,12 @@ export interface GCRApplyResult { appliedEditsCount: number } +/** Per-assignment record for the gcr_assigned_txs relation. */ +export interface AssignedTxRecord { + txHash: string + blockNumber: number +} + type IndexedSideEffect = { txIndex: number fn: () => Promise @@ -170,183 +155,43 @@ export default class HandleGCR { "escrow", ]) - static async getNativeStatus( - publicKey: string, - options: GetNativeStatusOptions = { - balance: true, - nonce: true, - txList: false, - identities: true, - extended: false, - }, - ): Promise { - const response: RPCResponse = _.cloneDeep(emptyResponse) - // Getting the datasource - const db = await Datasource.getInstance() - const globalChangeRegistryRepository = db - .getDataSource() - .getRepository(GlobalChangeRegistry) - // Getting the status native data - const globalChangeRegistrySearch = - await globalChangeRegistryRepository.findOneBy({ - publicKey: publicKey, - }) - if (!globalChangeRegistrySearch) { - response.response = "Address not found" - response.result = 404 - return response - } - // Preparing the response - const globalChangeRegistryData: GlobalChangeRegistry = { - id: globalChangeRegistrySearch.id, - publicKey: globalChangeRegistrySearch.publicKey, - details: globalChangeRegistrySearch.details, - extended: globalChangeRegistrySearch.extended, - } - // Selecting only the requested data - if (options.balance) { - globalChangeRegistryData.details.content.balance = - globalChangeRegistrySearch.details.content.balance - } - if (options.nonce) { - globalChangeRegistryData.details.content.nonce = - globalChangeRegistrySearch.details.content.nonce - } - if (options.txList) { - globalChangeRegistryData.details.content.txs = - globalChangeRegistrySearch.details.content.txs - } - if (options.identities) { - globalChangeRegistryData.details.content.identities = - globalChangeRegistrySearch.details.content.identities - } - if (options.extended) { - globalChangeRegistryData.extended = - globalChangeRegistrySearch.extended - } - response.response = globalChangeRegistryData - return response - } - - static async getNativeProperties( - publicKey: string, - options: GetNativePropertiesOptions = { - tokens: true, - nfts: true, - xm: true, - web2: true, - other: false, - }, - ): Promise { - const response: RPCResponse = _.cloneDeep(emptyResponse) - // Getting the datasource - const db = await Datasource.getInstance() - const gcrExtendedRepository = db - .getDataSource() - .getRepository(GlobalChangeRegistry) - // Getting the status properties data - const repositorySearch = await gcrExtendedRepository.findOneBy({ - publicKey: publicKey, - }) - const gcrExtendedSearch = repositorySearch.extended - if (!gcrExtendedSearch) { - response.response = "Address not found" - response.result = 404 - return response - } - // Preparing the response - const gcrExtendedData: GCRExtended = { - tokens: gcrExtendedSearch.tokens, - nfts: gcrExtendedSearch.nfts, - xm: gcrExtendedSearch.xm, - web2: gcrExtendedSearch.web2, - other: gcrExtendedSearch.other, - } - // Selecting only the requested data - if (options.tokens) { - gcrExtendedData.tokens = gcrExtendedSearch.tokens - } - if (options.nfts) { - gcrExtendedData.nfts = gcrExtendedSearch.nfts - } - if (options.xm) { - gcrExtendedData.xm = gcrExtendedSearch.xm - } - if (options.web2) { - gcrExtendedData.web2 = gcrExtendedSearch.web2 - } - response.response = gcrExtendedData - return response - } - - static async getNativeSubnetsTxs( - subnetId: string, - options: GetNativeSubnetsTxsOptions = { - txData: true, - }, - ): Promise { - const response: RPCResponse = _.cloneDeep(emptyResponse) - const db = await Datasource.getInstance() - const gcrSubnetsTxsRepository = db - .getDataSource() - .getRepository(GCRSubnetsTxs) - // Getting the status subnets txs data - const gcrSubnetsTxsSearch = await gcrSubnetsTxsRepository.findBy({ - subnet_id: subnetId, - }) - if (!gcrSubnetsTxsSearch) { - response.response = "Subnet not found" - response.result = 404 - return response - } - // Preparing the response - const gcrSubnetsTxsData: GCRSubnetsTxs[] = [] - // Selecting only the requested data - if (!options.txData) { - for (const tx of gcrSubnetsTxsSearch) { - tx.tx_data = null - gcrSubnetsTxsData.push(tx) - } - } - response.response = gcrSubnetsTxsData - return response - } - /** - * Bulk update assignedTxs using raw SQL for efficiency + * Record (pubkey, txHash, blockNumber) assignments into gcr_assigned_txs. + * + * Replaces the previous design where these tuples were appended to a + * jsonb array on gcr_main. The append-rewrite pattern caused unbounded + * TOAST churn (see MoveAssignedTxsToOwnTable migration for context). + * + * Idempotent via the (pubkey, tx_hash) primary key + orIgnore() — safe + * during rollback or replay where the same (pubkey, tx) pair recurs. */ static async bulkUpdateAssignedTxs( - updates: Map, + updates: Map, ): Promise { if (updates.size === 0) return - const db = await Datasource.getInstance() - const queryRunner = db.getDataSource().createQueryRunner() - - try { - // Build VALUES clause with proper escaping - // assignedTxs is jsonb, so we use jsonb arrays and || operator for concatenation - const valueEntries: string[] = [] - for (const [pubkey, txHashes] of updates.entries()) { - const escapedPubkey = pubkey.replace(/'/g, "''") - // Create a JSON array string for jsonb - const jsonArray = JSON.stringify(txHashes).replace(/'/g, "''") - valueEntries.push( - `('${escapedPubkey}'::text, '${jsonArray}'::jsonb)`, - ) + const rows: { pubkey: string; txHash: string; blockNumber: number }[] = + [] + for (const [pubkey, records] of updates.entries()) { + for (const r of records) { + rows.push({ + pubkey, + txHash: r.txHash, + blockNumber: r.blockNumber, + }) } - - const sql = ` - UPDATE gcr_main AS g - SET "assignedTxs" = COALESCE(g."assignedTxs", '[]'::jsonb) || v.new_txs, - "updatedAt" = NOW() - FROM (VALUES ${valueEntries.join(",\n")}) AS v(pubkey, new_txs) - WHERE g.pubkey = v.pubkey - ` - - await queryRunner.query(sql) - } finally { - await queryRunner.release() + } + if (rows.length === 0) return + + const repo = dataSource.getRepository(GCRAssignedTx) + for (let i = 0; i < rows.length; i += CHUNK_ASSIGNED_TXS) { + const chunk = rows.slice(i, i + CHUNK_ASSIGNED_TXS) + await repo + .createQueryBuilder() + .insert() + .values(chunk) + .orIgnore() + .execute() } } @@ -716,7 +561,7 @@ export default class HandleGCR { } const end = Date.now() - log.debug( + log.only( `[partitionIndependentTxs] Time taken: ${end - now}ms for ${txs.length} txs`, ) @@ -732,9 +577,14 @@ export default class HandleGCR { const successful: string[] = [] const failed: string[] = [] const sideEffects: IndexedSideEffect[] = [] - const assignedTxs = new Map() + const assignedTxs = new Map() + + for (let j = 0; j < group.length; j++) { + if (j > 0 && j % 8 === 0) { + await new Promise(resolve => setImmediate(resolve)) + } - for (const tx of group) { + const tx = group[j] const applyResult = await HandleGCR.applyTransaction( entities, tx, @@ -760,7 +610,10 @@ export default class HandleGCR { bucket = [] assignedTxs.set(sender, bucket) } - bucket.push(tx.hash) + bucket.push({ + txHash: tx.hash, + blockNumber: tx.blockNumber ?? 0, + }) } successful.push(tx.hash) @@ -784,7 +637,7 @@ export default class HandleGCR { log.debug("Applying GCR Edits for merged mempool (parallel groups)") const now = Date.now() - const assignedTxsUpdates = new Map() + const assignedTxsUpdates = new Map() // filter out txs that don't mutate entities const toFilter = new Set([ @@ -803,7 +656,10 @@ export default class HandleGCR { if (sender) { const bucket = assignedTxsUpdates.get(sender) || [] - bucket.push(tx.hash) + bucket.push({ + txHash: tx.hash, + blockNumber: tx.blockNumber ?? 0, + }) assignedTxsUpdates.set(sender, bucket) } } else { @@ -855,12 +711,12 @@ export default class HandleGCR { for (const h of r.successful) successfulSet.add(h) for (const h of r.failed) failedSet.add(h) allIndexedSideEffects.push(...r.sideEffects) - for (const [sender, hashes] of r.assignedTxs) { + for (const [sender, records] of r.assignedTxs) { const existing = assignedTxsUpdates.get(sender) if (existing) { - existing.push(...hashes) + existing.push(...records) } else { - assignedTxsUpdates.set(sender, hashes) + assignedTxsUpdates.set(sender, records) } } } @@ -884,16 +740,20 @@ export default class HandleGCR { log.debug( `[applyTransactions] Updating ${assignedTxsUpdates.size} assignedTxs`, ) + + // TODO: Move this to after transactions have been saved to the database await this.bulkUpdateAssignedTxs(assignedTxsUpdates) } }) const end = Date.now() - log.debug( + log.only( `[applyTransactions] Time taken: ${(end - now) / 1000}s for ${finalTxs.length} txs across ${groups.length} groups (${txs.length - finalTxs.length} non-state-mutating skipped)`, ) - log.debug(`[applyTransactions] Non-state-mutating skipped: ${txs.length - finalTxs.length}`) - log.debug(`[applyTransactions] Total txs: ${txs.length}`) + log.only( + `[applyTransactions] Non-state-mutating skipped: ${txs.length - finalTxs.length}`, + ) + log.only(`[applyTransactions] Total txs: ${txs.length}`) return { successfulTxs, failedTxs } } @@ -908,6 +768,7 @@ export default class HandleGCR { entities: GCREntityCaches, sideEffects: (() => Promise)[], ) { + const now = Date.now() // Save GCRMain entities const entitiesToSave = entities.accounts.values().toArray() entitiesToSave.sort((a, b) => a.pubkey.localeCompare(b.pubkey)) @@ -983,6 +844,11 @@ export default class HandleGCR { ) } } + + const end = Date.now() + log.only( + `[saveGCREditChanges] Time taken: ${(end - now) / 1000}s for ${sideEffects.length} side effects`, + ) } // REVIEW Implement the execution of GCREdit objects @@ -1374,55 +1240,6 @@ export default class HandleGCR { ) } - // ! SECTION GCREdit methods - - // Assign methods // ? Probably to remove - - // TODO We have to port these methods from gcr.ts, now they are just proxies - assign = { - xm: assignXM, - web2: assignWeb2, - identity: { - assignFromWrite: IdentityManager.inferIdentityFromWrite, - }, - } - - // This is a proxy to the manageNative methods for simplicity - native = manageNative - - // Utilities - utilities = { - ensureGCRForUser, - } - - // State save methods - save = gcrStateSave - - // Hash methods - hash = { - tables: hashGCRTables, - } - - // JSONB methods - jsonb = { - get: GCRJsonbHandler.getJSONBValue, - update: GCRJsonbHandler.updateJSONBValue, - } - - private static async getRepositories() { - const db = await Datasource.getInstance() - const dataSource = db.getDataSource() - - return { - main: dataSource.getRepository(GCRMain), - hashes: dataSource.getRepository(GCRHashes), - subnetsTxs: dataSource.getRepository(GCRSubnetsTxs), - tracker: dataSource.getRepository(GCRTracker), - tlsnotary: dataSource.getRepository(GCRTLSNotary), - storageProgram: dataSource.getRepository(GCRStorageProgram), - } - } - // Create methods /** * Creates a new GCRMain account. @@ -1457,7 +1274,6 @@ export default class HandleGCR { ud: [], } - account.assignedTxs = [] account.nonce = fillData["nonce"] || 0 account.points = fillData["points"] || { totalPoints: 0, diff --git a/src/libs/blockchain/mempool.ts b/src/libs/blockchain/mempool.ts index 212b01357..686fb7f3c 100644 --- a/src/libs/blockchain/mempool.ts +++ b/src/libs/blockchain/mempool.ts @@ -9,13 +9,14 @@ import { } from "typeorm" import Datasource from "@/model/datasource" -import TxUtils from "./transaction" import log from "src/utilities/logger" import { MempoolTx } from "@/model/entities/Mempool" import { Transaction } from "@kynesyslabs/demosdk/types" import SecretaryManager from "../consensus/v2/types/secretaryManager" import Chain from "./chain" import { getSharedState } from "@/utilities/sharedState" +import TxValidatorPool from "./validation/txValidatorPool" +import { CHUNK_MEMPOOL_TX, chunkedInsert } from "./chainDb" export default class Mempool { public static repo: Repository = null @@ -108,15 +109,12 @@ export default class Mempool { } } - let blockNumber: number = blockRef ?? undefined + if (blockRef === undefined) { + blockRef = getSharedState.lastBlockNumber + 1 - // INFO: If we're in consensus, move tx to next block - if (getSharedState.inConsensusLoop && !blockNumber) { - blockNumber = SecretaryManager.lastBlockRef + 1 - } - - if (!blockNumber) { - blockNumber = (await Chain.getLastBlockNumber()) + 1 + if (getSharedState.inConsensusLoop) { + blockRef = SecretaryManager.lastBlockRef + 1 + } } try { @@ -124,7 +122,7 @@ export default class Mempool { ...transaction, timestamp: BigInt(transaction.content.timestamp), nonce: transaction.content.nonce, - blockNumber: blockNumber, + blockNumber: blockRef, }) return { @@ -160,9 +158,9 @@ export default class Mempool { } public static async receive(incoming: Transaction[]) { - if (!getSharedState.inConsensusLoop) { + if (incoming.length === 0) { return { - success: false, + success: true, mempool: [], } } @@ -177,51 +175,53 @@ export default class Mempool { tx => !existingHashes[tx.hash], ) + log.only( + "[Mempool.receive] Unseen transcations: " + + JSON.stringify( + unseenTransactions.map(tx => tx.hash), + null, + 2, + ), + ) + log.only( + `[Mempool.receive] Unseen transactions: ${unseenTransactions.length}`, + ) + if (unseenTransactions.length === 0) { + const incomingHashes = new Set(incoming.map(tx => tx.hash)) const finalPool = await this.getMempool(blockNumber) const final = finalPool.filter( - tx => tx.blockNumber === blockNumber, + tx => + tx.blockNumber <= blockNumber && + !incomingHashes.has(tx.hash), ) + return { success: true, mempool: final, } } - const validateOne = async ( - tx: Transaction, - ): Promise => { - const isCoherent = TxUtils.isCoherent(tx) - if (!isCoherent) { - log.error( - "[Mempool.receive] Transaction is not coherent: " + tx.hash, - ) - return null - } + const now = Date.now() + const results = + await TxValidatorPool.getInstance().validate(unseenTransactions) + const end = Date.now() + log.only( + `[Mempool.receive] TxValidatorPool.validate() took ${end - now}ms for ${unseenTransactions.length} transactions`, + ) - const { success: signatureValid } = - await TxUtils.validateSignature(tx) - if (!signatureValid) { - log.error( - "[Mempool.receive] Transaction signature is not valid: " + - tx.hash, - ) - return null + const validTransactions: Transaction[] = [] + for (let i = 0; i < unseenTransactions.length; i++) { + const r = results[i] + if (!r.valid) { + log.error(`[Mempool.receive] Invalid tx ${r.hash}: ${r.reason}`) + continue } - - return tx + validTransactions.push(unseenTransactions[i]) } - const BATCH_SIZE = 16 - const validationResults: (Transaction | null)[] = [] - for (let i = 0; i < unseenTransactions.length; i += BATCH_SIZE) { - const batch = unseenTransactions.slice(i, i + BATCH_SIZE) - const results = await Promise.all(batch.map(validateOne)) - validationResults.push(...results) - } - - const validTransactions = validationResults.filter( - (tx): tx is Transaction => tx !== null, + log.only( + `[Mempool.receive] Valid transactions: ${validTransactions.length}`, ) for (const tx of validTransactions) { @@ -230,17 +230,18 @@ export default class Mempool { if (validTransactions.length > 0) { try { - const insertResult = await this.repo - .createQueryBuilder() - .insert() - .into(MempoolTx) - .values(validTransactions) - .orIgnore() - .execute() - - const insertedCount = insertResult.identifiers.length - log.debug( - `[Mempool.receive] Inserted ${insertedCount}/${validTransactions.length} transactions`, + const { inserted } = await chunkedInsert( + this.repo, + MempoolTx, + validTransactions as any[], + CHUNK_MEMPOOL_TX, + { + conflictTarget: ["hash"], + overwrite: ["blockNumber"], + }, + ) + log.only( + `[Mempool.receive] Inserted ${inserted}/${validTransactions.length} transactions`, ) } catch (error) { log.error("[Mempool.receive] Error saving received mempool:") @@ -248,7 +249,10 @@ export default class Mempool { } } + // DEBUG: Confirm all inserted transactions are in the mempool + const finalPool = await this.getMempool(blockNumber) + log.only("[Mempool.receive] Final pool size: " + finalPool.length) // INFO: Redundancy // INFO: Return the difference to the caller node diff --git a/src/libs/blockchain/routines/Sync.ts b/src/libs/blockchain/routines/Sync.ts index bb1059f9e..d0daa1833 100644 --- a/src/libs/blockchain/routines/Sync.ts +++ b/src/libs/blockchain/routines/Sync.ts @@ -38,6 +38,8 @@ import { import { BroadcastManager } from "@/libs/communications/broadcastManager" import { Waiter } from "@/utilities/waiter" import Mempool from "../mempool" +import SecretaryManager from "@/libs/consensus/v2/types/secretaryManager" +import { getLastBlockSigners } from "../chainBlocks" const peerManager = PeerManager.getInstance() async function sleep(time: number) { @@ -312,6 +314,12 @@ async function verifyLastBlockIntegrity( * @returns True if the block was synced successfully, false otherwise */ export async function syncBlock(block: Block, peer: Peer) { + const exists = await Chain.getBlockByNumber(block.number) + if (exists) { + log.debug("Block already exists, skipping ...") + return true + } + await Chain.insertBlock(block, [], null, false) log.debug("Block inserted successfully") log.debug( @@ -348,62 +356,62 @@ export async function syncBlock(block: Block, peer: Peer) { return true } -/** - * - * @param peer The peer to download the block from - * @param blockToAsk The block number to download - * @returns The block if downloaded successfully, false otherwise - */ -async function downloadBlock(peer: Peer, blockToAsk: number) { - const blockRequest: RPCRequest = { - method: "nodeCall", - params: [ - { - message: "getBlockByNumber", - data: { blockNumber: blockToAsk.toString() }, - muid: null, - }, - ], - } - - const blockResponse = await peer.longCall(blockRequest, true, { - protocol: "http", - sleepTime: 1000, - retries: 3, - }) - log.debug("Block response: " + blockResponse.result) - - // INFO: Handle max retries reached - if (blockResponse.result === 400) { - log.info("[fastSync] Peer is offline") - // TODO: Test this! - throw new PeerUnreachableError("Peer is offline") - } - - if (blockResponse.result === 404) { - log.error("[fastSync] Block not found") - log.error("BLOCK TO ASK: " + blockToAsk) - log.error("PEER: " + peer.connection.string) - - throw new BlockNotFoundError("Block not found") - } - - if (blockResponse.result === 200) { - log.debug( - `[SYNC] downloadBlock - Block response received for block: ${blockToAsk}`, - ) - const block = blockResponse.response as Block - - if (!block) { - log.error("[downloadBlock] Block not received") - return false - } - - return await syncBlock(block, peer) - } - - return false -} +// /** +// * +// * @param peer The peer to download the block from +// * @param blockToAsk The block number to download +// * @returns The block if downloaded successfully, false otherwise +// */ +// async function downloadBlock(peer: Peer, blockToAsk: number) { +// const blockRequest: RPCRequest = { +// method: "nodeCall", +// params: [ +// { +// message: "getBlockByNumber", +// data: { blockNumber: blockToAsk.toString() }, +// muid: null, +// }, +// ], +// } + +// const blockResponse = await peer.longCall(blockRequest, true, { +// protocol: "http", +// sleepTime: 1000, +// retries: 3, +// }) +// log.debug("Block response: " + blockResponse.result) + +// // INFO: Handle max retries reached +// if (blockResponse.result === 400) { +// log.info("[fastSync] Peer is offline") +// // TODO: Test this! +// throw new PeerUnreachableError("Peer is offline") +// } + +// if (blockResponse.result === 404) { +// log.error("[fastSync] Block not found") +// log.error("BLOCK TO ASK: " + blockToAsk) +// log.error("PEER: " + peer.connection.string) + +// throw new BlockNotFoundError("Block not found") +// } + +// if (blockResponse.result === 200) { +// log.debug( +// `[SYNC] downloadBlock - Block response received for block: ${blockToAsk}`, +// ) +// const block = blockResponse.response as Block + +// if (!block) { +// log.error("[downloadBlock] Block not received") +// return false +// } + +// return await syncBlock(block, peer) +// } + +// return false +// } // Helper function to ask for transactions in batches export async function askTxsForBlocksBatch( @@ -483,7 +491,7 @@ async function batchDownloadBlocks( params: [ { message: "getBlocks", - data: { start: startBlock + limit, limit }, + data: { start: startBlock + limit - 1, limit }, muid: null, }, ], @@ -653,6 +661,11 @@ async function requestBlocks(): Promise { let peer = highestBlockPeer() while (getSharedState.lastBlockNumber < latestBlock()) { + // if (latestBlock() === SecretaryManager.lastBlockRef) { + // log.debug("Attempting to sync consensus block, returning ...") + // return true + // } + log.debug("[requestBlocks] Requesting blocks ... 🔄🔄🔄🔄🔄🔄🔄🔄🔄") const startBlock = getSharedState.lastBlockNumber + 1 const endBlock = latestBlock() @@ -856,7 +869,8 @@ async function fastSyncRoutine(peers: Peer[] = []) { } while (!(await requestBlocks())) { - if (getSharedState.isShuttingDown || getSharedState.fastSyncAborted) return false + if (getSharedState.isShuttingDown || getSharedState.fastSyncAborted) + return false log.debug( "[fastSync] Request blocks failed, retrying ... ⛔️⛔️⛔️⛔️⛔️⛔️⛔️⛔️", ) @@ -867,12 +881,13 @@ async function fastSyncRoutine(peers: Peer[] = []) { await Mempool.cleanMempool() // await waitForNextBlock() - while (!(await waitForNextBlock())) { - if (getSharedState.isShuttingDown || getSharedState.fastSyncAborted) return false - log.debug( - "[fastSync] Failed to wait for next block, retrying ... ⛔️⛔️⛔️⛔️⛔️⛔️⛔️⛔️", - ) - } + // while (!(await waitForNextBlock())) { + // if (getSharedState.isShuttingDown || getSharedState.fastSyncAborted) + // return false + // log.debug( + // "[fastSync] Failed to wait for next block, retrying ... ⛔️⛔️⛔️⛔️⛔️⛔️⛔️⛔️", + // ) + // } log.debug("[fastSync] Wait for next block complete! 🥳🥳🥳🥳🥳🥳🥳🥳🥳") } @@ -883,26 +898,56 @@ async function fastSyncRoutine(peers: Peer[] = []) { export async function fastSync( peers: Peer[] = [], from: string, -): Promise { - if (getSharedState.inSyncLoop) { - log.debug("[fastSync] Sync loop already running, skipping") - return true - } +): Promise<{ latestChainBlock: number; ourLatestBlock: number }> { + // if (getSharedState.inSyncLoop) { + // log.debug("[fastSync] Sync loop already running, skipping") + + // return { + // latestChainBlock: latestBlock(), + // ourLatestBlock: getSharedState.lastBlockNumber, + // } + // } - getSharedState.inSyncLoop = true - getSharedState.fastSyncAborted = false + log.debug("[fastSync] Starting sync loop") try { + getSharedState.inSyncLoop = true + getSharedState.fastSyncAborted = false + + // if our difference is greater than 2 blocks, set our sync status to false and broadcast + if (getSharedState.syncStatus) { + const networkHighest = latestBlock() + const ourHighest = getSharedState.lastBlockNumber + const difference = networkHighest - ourHighest + + if (difference >= 2) { + getSharedState.syncStatus = false + await BroadcastManager.broadcastOurSyncData() + log.debug( + "[fastSync] Network highest block is more than 2 blocks ahead of our highest block, setting sync status to false and broadcasting", + ) + } + } + let synced: boolean if (getSharedState.fastSyncCount > 0) { const result = await Promise.race([ - fastSyncRoutine(peers).then((v) => ({ kind: "done" as const, value: v })), - sleep(FAST_SYNC_TIMEOUT_MS).then(() => ({ kind: "timeout" as const, value: false })), + fastSyncRoutine(peers).then(v => ({ + kind: "done" as const, + value: v, + })), + sleep(FAST_SYNC_TIMEOUT_MS).then(() => ({ + kind: "timeout" as const, + value: false, + })), ]) if (result.kind === "timeout") { getSharedState.fastSyncAborted = true log.warn("[fastSync] Timed out after 30s, aborting") - return false + return { + latestChainBlock: latestBlock(), + ourLatestBlock: getSharedState.lastBlockNumber, + } } synced = result.value @@ -924,10 +969,94 @@ export async function fastSync( from, ) - return true + return { + latestChainBlock: lastBlockNumber, + ourLatestBlock: getSharedState.lastBlockNumber, + } } finally { getSharedState.fastSyncAborted = false getSharedState.inSyncLoop = false log.debug("[fastSync] Sync loop ended") } } + +/** + * Block the consensus until peers reach the same block as us. + * + * @param lastBlockSignersOnly - If true, only wait for peers that signed the last block. + * If false, wait for online peers that are level or 1 block behind. + * @returns False if any peer is ahead of us (caller should abort the round); true otherwise. + */ +export async function waitForPeerStatus( + lastBlockSignersOnly = true, +): Promise { + const POLL_MS = 100 + const TIMEOUT_MS = 30_000 + + const ourBlock = getSharedState.lastBlockNumber + const ourHash = getSharedState.lastBlockHash + const selfId = getSharedState.publicKeyHex + + let signerIds: Set | null = null + + if (lastBlockSignersOnly) { + const signers = await getLastBlockSigners() + const others = signers.filter(id => id !== selfId) + if (others.length === 0) { + log.debug("[waitForPeerStatus] No prior signers, skipping") + return true + } + signerIds = new Set(others) + log.only( + "[waitForPeerStatus] Last block signers: " + + JSON.stringify(Array.from(signerIds), null, 2), + ) + } + + const start = Date.now() + while (Date.now() - start < TIMEOUT_MS) { + const onlinePeers = peerManager.getPeers() + + // Abort if any peer is ahead — we're stale and shouldn't drive consensus + const ahead = onlinePeers.filter( + p => p.status.online && p.sync.status && p.sync.block > ourBlock, + ) + if (ahead.length > 0) { + log.error( + `[waitForPeerStatus] ${ahead.length} peer(s) ahead at block ${ourBlock}, aborting`, + ) + return false + } + + const waitFor = signerIds + ? onlinePeers.filter(p => signerIds.has(p.identity)) + : onlinePeers.filter(p => p.sync.block >= ourBlock - 2) + + if (waitFor.length === 0) { + log.only("[waitForPeerStatus] No target peers to wait for") + return true + } + + const isAligned = (p: Peer) => + p.sync.block === ourBlock && p.sync.block_hash === ourHash + + if (waitFor.every(isAligned)) { + log.only( + `[waitForPeerStatus] ${waitFor.length} peer(s) aligned at block ${ourBlock}, after ${Date.now() - start}ms`, + ) + return true + } + + const lagging = waitFor.filter(p => !isAligned(p)).length + log.only( + `[waitForPeerStatus] Waiting on ${lagging}/${waitFor.length} at block ${ourBlock}`, + ) + log.only("😒😒😒😒😒😒😒😒😒😒😒😒😒😒😒😒😒😒😒😒😒😒😒😒😒😒😒😒😒😒") + await sleep(POLL_MS) + } + + log.only( + `[waitForPeerStatus] Timeout after ${TIMEOUT_MS}ms, proceeding best-effort 🙊`, + ) + return true +} diff --git a/src/libs/blockchain/routines/executeOperations.ts b/src/libs/blockchain/routines/executeOperations.ts deleted file mode 100644 index 485a17b0a..000000000 --- a/src/libs/blockchain/routines/executeOperations.ts +++ /dev/null @@ -1,175 +0,0 @@ -/* LICENSE - -© 2023 by KyneSys Labs, licensed under CC BY-NC-ND 4.0 - -Full license text: https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode -Human readable license: https://creativecommons.org/licenses/by-nc-nd/4.0/ - -KyneSys Labs: https://www.kynesys.xyz/ - -*/ - -/* NOTE - executeOperations is called AFTER the transaction validated by the consensus (or immediately if - it is the genesis transaction) and is responsible for reflecting the changes in the database. - - executeSequence is called for each address to execute the operations contained. -*/ - -import { Operation, OperationResult } from "@kynesyslabs/demosdk/types" - -import Block from "../block" -import log from "src/utilities/logger" -import subOperations from "./subOperations" - -// NOTE The Actor object is designed to represent a single operator and the status of its operations -export interface Actor { - operations: Map -} - -// ANCHOR Execute operations and merge GCR registry into the chain based on the status -export default async function executeOperations( - operations: Operation[], - block: Block = null, -): Promise> { - log.debug("Executing operations") - //console.log("executeOperations", operations) - const results = new Map() - // First of all we divide the operations into groups of addresses - let groups: Map = new Map() - const sortedGroups = groups - groups = divideByAddress(operations) - // Then for each group we sort it by fees - for (let group of groups.values()) { - const address = group[0].actor // Each group should have the same actor - group = sortByNumeric(group, "fees") - sortedGroups.set(address, group) - } - // For every group we execute the operations and set the results - for (const group of sortedGroups.values()) { - const address = group[0].actor - const groupResults = await executeSequence(group, block) - results.set(address, groupResults) - } - // Returns the complex result - return results -} - -// ANCHOR Non exported internal methods and mechanisms - -// INFO Execute a sorted sequence of operations made by the same operator -async function executeSequence( - operations: Operation[], - block: Block = null, -): Promise { - const results: Actor = { - operations: new Map(), - } - // Execute the operations sequentially - for (let i = 0; i < operations.length; i++) { - const hash = operations[i].hash - let error = "no error occurred" - let valid = true // Until proven otherwise - let result: OperationResult = { - success: true, - message: error, - } - // TODO Implement nonce verification united to fee control to expose replacements - // TODO How to handle all the txs together? In the results registry we will have to tinker a lot - // ANCHOR Dispatching the operation to the appropriate method - switch (operations[i].operator) { - case "genesis": - log.debug("Genesis block: applying genesis operations") - result = await subOperations.genesis(operations[i], block) - break - case "transfer_native": - result = await subOperations.transferNative(operations[i]) - break - case "add_native": - result = await subOperations.addNative(operations[i]) - results.operations.set(operations[i], result) - break - case "remove_native": - result = await subOperations.removeNative(operations[i]) - results.operations.set(operations[i], result) - break - case "add_asset": - result = await subOperations.addAsset(operations[i]) - results.operations.set(operations[i], result) - break - case "remove_asset": - result = await subOperations.removeAsset(operations[i]) - results.operations.set(operations[i], result) - break - - // REVIEW - // TODO Harmonize with deriveMempoolOperation - case "assign_xm": - result = await subOperations.gcrRoutines.assignXM(operations[i]) - results.operations.set(operations[i], result) - break - case "assign_web2": - result = await subOperations.gcrRoutines.assignWeb2( - operations[i], - ) - results.operations.set(operations[i], result) - break - - default: - valid = false - error = "unknown operator" - break - } - operations[i].status = valid - results.operations.set(operations[i], { - success: valid, - message: valid - ? "Transaction executed" - : "Transaction failed due to: " + error, - }) - } - // Returns the success and message for each operation - return results -} - -// INFO Given a list of operations and a property name, sort the list by that property value -function sortByNumeric( - list: Operation[], - key: string, - ascending = true, -): Operation[] { - let sorted: Operation[] = [] - for (let i = 0; i < list.length; i++) { - const operation = list[i] - // Creating the first element if is not present yet - if (sorted.length === 0) { - sorted = [operation] - continue - } - // Sorting the list by the given key one by one - for (let j = 0; j < sorted.length; j++) { - if (sorted[j][key] < operation[key]) { - sorted.splice(j, 0, operation) - break - } else if (sorted[j][key] > operation[key]) { - sorted.splice(j + 1, 0, operation) - break - } - } - } - return sorted -} - -// INFO Given a list of operations, divide them in a map of addresses to their corresponding operations -function divideByAddress(operations: Operation[]): Map { - const divided: Map = new Map() - for (let i = 0; i < operations.length; i++) { - const address = operations[i].actor - if (!divided.has(address)) { - divided.set(address, [operations[i]]) - } else { - divided.get(address).push(operations[i]) - } - } - return divided -} diff --git a/src/libs/blockchain/routines/subOperations.ts b/src/libs/blockchain/routines/subOperations.ts index 6b8b4f7ec..38512bc78 100644 --- a/src/libs/blockchain/routines/subOperations.ts +++ b/src/libs/blockchain/routines/subOperations.ts @@ -128,8 +128,8 @@ export default class SubOperations { message: "Invalid amount", } } - const balanceFrom = await GCR.getGCRNativeBalance(from) - const balanceTo = await GCR.getGCRNativeBalance(to) + const balanceFrom = await GCR.getAccountBalance(from) + const balanceTo = await GCR.getAccountBalance(to) // Sanity checks if (amount === 0n) { @@ -212,7 +212,7 @@ export default class SubOperations { static async addNative(operation: Operation): Promise { const to: string = operation.params.to const amount: string = operation.params.amount - const balanceTo = await GCR.getGCRNativeBalance(to) + const balanceTo = await GCR.getAccountBalance(to) // Sanity checks if (amount === "0") { return { @@ -252,7 +252,7 @@ export default class SubOperations { static async removeNative(operation: Operation): Promise { const to: string = operation.params.to const amount: string = operation.params.amount - const balanceTo = await GCR.getGCRNativeBalance(to) + const balanceTo = await GCR.getAccountBalance(to) // Sanity checks if (amount === "0") { return { diff --git a/src/libs/blockchain/routines/validateTransaction.ts b/src/libs/blockchain/routines/validateTransaction.ts index 43340e6e6..5fe764cfd 100644 --- a/src/libs/blockchain/routines/validateTransaction.ts +++ b/src/libs/blockchain/routines/validateTransaction.ts @@ -13,9 +13,7 @@ import { pki } from "node-forge" import Chain from "src/libs/blockchain/chain" import GCR from "src/libs/blockchain/gcr/gcr" import calculateCurrentGas from "src/libs/blockchain/routines/calculateCurrentGas" -import executeNativeTransaction from "src/libs/blockchain/routines/executeNativeTransaction" import Transaction from "src/libs/blockchain/transaction" -import Cryptography from "src/libs/crypto/cryptography" import Hashing from "src/libs/crypto/hashing" import { getSharedState } from "src/utilities/sharedState" import log from "src/utilities/logger" @@ -23,6 +21,7 @@ import { Operation, ValidityData } from "@kynesyslabs/demosdk/types" import { forgeToHex } from "src/libs/crypto/forgeUtils" import _ from "lodash" import { ucrypto, uint8ArrayToHex } from "@kynesyslabs/demosdk/encryption" +import TxValidatorPool from "../validation/txValidatorPool" import { isForkActive } from "@/forks" import { applyGasFeeSeparation } from "@/libs/blockchain/routines/applyGasFeeSeparation" @@ -34,7 +33,12 @@ export async function confirmTransaction( ): Promise { log.info("TX", "[Native Tx Validation] Validating transaction...") // Getting the current block number + const getLastBlockNumberStart = Date.now() const referenceBlock = await Chain.getLastBlockNumber() + const getLastBlockNumberEnd = Date.now() + log.only( + `[confirmTransaction] Get last block number in ${getLastBlockNumberEnd - getLastBlockNumberStart}ms`, + ) // REVIEW This should work just fine log.debug( `[TX] confirmTransaction - Signature: ${JSON.stringify(tx.signature)}`, @@ -196,7 +200,7 @@ async function signValidityData(data: ValidityData): Promise { const hash = Hashing.sha256(JSON.stringify(data.data)) // return data - const signature = await ucrypto.sign( + const signature = await TxValidatorPool.getInstance().sign( getSharedState.signingAlgorithm, new TextEncoder().encode(hash), ) @@ -231,13 +235,16 @@ async function defineGas( log.debug(`[TX] defineGas - Calculating gas for: ${from}`) } catch (e) { const errorMsg = e instanceof Error ? e.message : String(e) - log.error("TX", `[Native Tx Validation] [FROM ERROR] No 'from' field found in the transaction: ${errorMsg}`) + log.error( + "TX", + `[Native Tx Validation] [FROM ERROR] No 'from' field found in the transaction: ${errorMsg}`, + ) validityData.data.message = "[Native Tx Validation] [FROM ERROR] No 'from' field found in the transaction\n" // Hash the validation data const hash = Hashing.sha256(JSON.stringify(validityData.data)) // Sign the hash - const signature = await ucrypto.sign( + const signature = await TxValidatorPool.getInstance().sign( getSharedState.signingAlgorithm, new TextEncoder().encode(hash), ) @@ -252,10 +259,13 @@ async function defineGas( // via BigInt() coercion below. let fromBalance = 0n try { - fromBalance = await GCR.getGCRNativeBalance(from) + fromBalance = await GCR.getAccountBalance(from) } catch (e) { const errorMsg = e instanceof Error ? e.message : String(e) - log.error("TX", `[Native Tx Validation] [BALANCE ERROR] No balance found for address ${from}: ${errorMsg}`) + log.error( + "TX", + `[Native Tx Validation] [BALANCE ERROR] No balance found for address ${from}: ${errorMsg}`, + ) validityData.data.message = "[Native Tx Validation] [BALANCE ERROR] No balance found for this address: " + from + @@ -263,7 +273,7 @@ async function defineGas( // Hash the validation data const hash = Hashing.sha256(JSON.stringify(validityData.data)) // Sign the hash - const signature = await ucrypto.sign( + const signature = await TxValidatorPool.getInstance().sign( getSharedState.signingAlgorithm, new TextEncoder().encode(hash), ) @@ -317,7 +327,7 @@ async function defineGas( // Hash the validation data const hash = Hashing.sha256(JSON.stringify(validityData.data)) // Sign the hash - const signature = await ucrypto.sign( + const signature = await TxValidatorPool.getInstance().sign( getSharedState.signingAlgorithm, new TextEncoder().encode(hash), ) @@ -358,29 +368,3 @@ export async function assignNonce(tx: Transaction): Promise { // while returning either true or false return validNonce } - -// TODO a verified transaction should be signed by the same rpc that verified it and should be only valid for the current consensus round -export async function broadcastVerifiedNativeTransaction( - validityData: ValidityData, -): Promise<[boolean, string, Operation[]?]> { - // REVIEW Execute or Revert the transaction - // NOTE executeTransaction returns an array of [success, message, operations] - // The operations are the Operation objects that are executed in the GCR after the consensus - // has confirmed the transaction in the block. - - const execution = await executeNativeTransaction( - validityData.data.transaction, - ) - if (!execution[0]) { - return [false, "Execution failed: " + execution[1]] - } - - // ANCHOR TX Pre-execution, operation derivation and GCR Operation registry update are defined here - - // NOTE Deprecated in favor of the GCREdit system - // and the gas will be deducted anyway - //console.log("[TX RECEIVED] Gas Operation added to the GCR\n") - //GCR.getInstance().operations.push(validityData.data.gas_operation) - - return execution -} diff --git a/src/libs/blockchain/transaction.ts b/src/libs/blockchain/transaction.ts index 747e76c3e..1138eb060 100644 --- a/src/libs/blockchain/transaction.ts +++ b/src/libs/blockchain/transaction.ts @@ -34,6 +34,9 @@ import { getSharedState } from "@/utilities/sharedState" import IdentityManager from "./gcr/gcr_routines/identityManager" import { SavedPqcIdentity } from "@/model/entities/types/IdentityTypes" import log from "src/utilities/logger" +import prefetchIdentities from "./validation/prefetchIdentities" +import { validateTxSignature } from "./validation/txValidator" +import TxValidatorPool from "./validation/txValidatorPool" import { serializeTransactionContent } from "@/forks" import { Transactions } from "@/model/entities/Transactions" @@ -173,7 +176,7 @@ export default class Transaction implements ITransaction { confirmation.data.validator = getSharedState.keypair .publicKey as Uint8Array confirmation.data.tx_hash_validated = tx.hash - const signature = await ucrypto.sign( + const signature = await TxValidatorPool.getInstance().sign( getSharedState.signingAlgorithm, new TextEncoder().encode(JSON.stringify(confirmation.data)), ) @@ -212,76 +215,18 @@ export default class Transaction implements ITransaction { } } - let ed25519SignatureVerified = false - - // INFO: If a PQC signer is used, make sure identity is in the GCR - // or there's an ed25519 signature to verify ownership of ed25519 address - if (tx.signature.type !== "ed25519") { - // INFO: check if sender's PQC pubkey is indexed in the GCR - if (!tx.ed25519_signature) { - const identities = - (await IdentityManager.getIdentities( - tx.content.from_ed25519_address, - "pqc", - )) || {} - - // INFO: Get all the indexed pubkeys for the PQC signer type (eg. falcon, etc.) - const indexedPubKeys: SavedPqcIdentity[] = - identities[tx.signature.type] || [] - - // INFO: Check if sender's PQC pubkey is indexed in PQC identities - const found = indexedPubKeys.find( - identity => identity.address === tx.content.from, - ) - - if (!found) { - return { - success: false, - message: - "Transaction is missing ed25519 signature, and the PQC signer is not added as an identity. Please provide an ed25519 signature or add the PQC signer as an identity for " + - tx.content.from_ed25519_address, - } - } - - // Verify the found key's signature with the tx's ed25519 address - ed25519SignatureVerified = await ucrypto.verify({ - algorithm: "ed25519", - message: new TextEncoder().encode(found.address), - publicKey: hexToUint8Array(tx.content.from_ed25519_address), - signature: hexToUint8Array(found.signature), - }) - } else { - // INFO: Verify ed25519 signature - ed25519SignatureVerified = await ucrypto.verify({ - algorithm: "ed25519", - message: new TextEncoder().encode(tx.hash), - publicKey: hexToUint8Array(tx.content.from_ed25519_address), - signature: hexToUint8Array(tx.ed25519_signature), - }) - } - } else { - ed25519SignatureVerified = true - } - - if (!ed25519SignatureVerified) { - return { - success: false, - message: "Ed25519 signature verification failed", - } - } - - const mainSignatureVerified = await ucrypto.verify({ - algorithm: tx.signature.type as SigningAlgorithm, - message: new TextEncoder().encode(tx.hash), - publicKey: hexToUint8Array(tx.content.from as string), - signature: hexToUint8Array(tx.signature.data), - }) + // Delegate the actual coherence-skipping signature verification to the + // pure validator so this method and Mempool.receive share one source of + // truth for the crypto rules. The DB lookup that the PQC-no-co-signature + // branch needs is pre-resolved here as a single-tx prefetch. + const hints = await prefetchIdentities([tx]) + const result = await validateTxSignature(tx, hints[tx.hash] ?? null) return { - success: mainSignatureVerified, - message: mainSignatureVerified + success: result.valid, + message: result.valid ? "Transaction signature verified" - : "Transaction signature verification failed", + : result.reason, } } @@ -532,10 +477,6 @@ export default class Transaction implements ITransaction { tx: Transaction, status = "confirmed", ): RawTransaction { - log.debug( - `[TX] toRawTransaction - Creating raw tx: hash=${tx.hash}, type=${tx.content.type}, status=${status}, blockNumber=${tx.blockNumber}`, - ) - // NOTE From and To can be either a string or a Buffer if (tx.content.to["data"]?.toString("hex")) { tx.content.to = tx.content.to["data"]?.toString("hex") @@ -544,9 +485,6 @@ export default class Transaction implements ITransaction { tx.content.from = tx.content.from["data"]?.toString("hex") } - log.debug( - `[TX] toRawTransaction - From: ${tx.content.from}, To: ${tx.content.to}`, - ) // REVIEW P5a: returns the SDK `RawTransaction` shape (`amount` and // fees as `string | number`). Callers (`insertTransaction`, // `chainBlocks.insertBlock`) pass this to TypeORM `save()`, which diff --git a/src/libs/blockchain/validation/prefetchIdentities.ts b/src/libs/blockchain/validation/prefetchIdentities.ts new file mode 100644 index 000000000..1c24f3c8c --- /dev/null +++ b/src/libs/blockchain/validation/prefetchIdentities.ts @@ -0,0 +1,57 @@ +import { In } from "typeorm" +import { Transaction } from "@kynesyslabs/demosdk/types" +import { GCRMain } from "@/model/entities/GCRv2/GCR_Main" +import { SavedPqcIdentity } from "@/model/entities/types/IdentityTypes" +import Datasource from "@/model/datasource" +import type { IdentityHintMap } from "./types" + +function normalizePubkey(addr: string): string { + return addr.startsWith("0x") ? addr : `0x${addr}` +} + +/** + * Pre-fetches the single PQC identity record each transaction needs to + * validate without hitting the DB inside the validator. Mirrors the lookup + * `TxUtils.validateSignature` performs via `IdentityManager.getIdentities`, + * but goes straight to the repository so unknown signers do NOT cause an + * empty `GCRMain` row to be created (which `ensureGCRForUser` would do). + * + * Returns a map keyed by `tx.hash`. Only PQC transactions without an + * ed25519 co-signature are present; all other tx hashes are absent and + * callers should treat that as a `null` hint. + */ +export default async function prefetchIdentities( + txs: Transaction[], +): Promise { + const needIdentity = txs.filter( + t => t.signature.type !== "ed25519" && !t.ed25519_signature, + ) + if (needIdentity.length === 0) return {} + + const addresses = new Set() + for (const tx of needIdentity) { + addresses.add(normalizePubkey(tx.content.from_ed25519_address)) + } + + const repo = (await Datasource.getInstance()) + .getDataSource() + .getRepository(GCRMain) + const rows = await repo.find({ + where: { pubkey: In(Array.from(addresses)) }, + select: ["pubkey", "identities"], + }) + + const pqcByPubkey = new Map>() + for (const row of rows) { + pqcByPubkey.set(row.pubkey, row.identities?.pqc ?? {}) + } + + const hints: IdentityHintMap = {} + for (const tx of needIdentity) { + const pubkey = normalizePubkey(tx.content.from_ed25519_address) + const entries = pqcByPubkey.get(pubkey)?.[tx.signature.type] ?? [] + hints[tx.hash] = + entries.find(e => e.address === tx.content.from) ?? null + } + return hints +} diff --git a/src/libs/blockchain/validation/txValidator.ts b/src/libs/blockchain/validation/txValidator.ts new file mode 100644 index 000000000..88a4dbe5e --- /dev/null +++ b/src/libs/blockchain/validation/txValidator.ts @@ -0,0 +1,112 @@ +import { Transaction } from "@kynesyslabs/demosdk/types" +import type { SigningAlgorithm } from "@kynesyslabs/demosdk/types" +// Import unifiedCrypto.js directly instead of "@kynesyslabs/demosdk/encryption": +// the package index re-exports zK, which transitively loads ffjavascript → +// web-worker. web-worker auto-runs its workerThread() startup against the host +// worker's workerData and throws TypeError: Cannot destructure property 'mod' +// from null, killing the worker before any handlers run. Going straight to +// unifiedCrypto.js avoids zK (and FHE/SEAL) and keeps this file safe to load +// inside a worker_threads / Bun Worker. +import { + unifiedCrypto as ucrypto, + hexToUint8Array, +} from "../../../../node_modules/@kynesyslabs/demosdk/build/encryption/unifiedCrypto.js" +import Hashing from "../../crypto/hashing" +import type { PqcIdentityHint, TxValidationResult } from "./types" + +/** + * SHA-256(JSON.stringify(content)) === tx.hash. No I/O, no logger. + */ +export function validateTxCoherence(tx: Transaction): TxValidationResult { + const derivedHash = Hashing.sha256(JSON.stringify(tx.content)) + if (derivedHash !== tx.hash) { + return { + hash: tx.hash, + valid: false, + reason: "Transaction is not coherent", + } + } + return { hash: tx.hash, valid: true } +} + +/** + * Pure signature validation. Same semantics as `TxUtils.validateSignature` + * (without the optional `sender` argument) but consults `hint` instead of + * `IdentityManager.getIdentities` for the PQC-no-co-signature branch. + * + * Error messages preserved byte-for-byte for downstream consumers. + */ +export async function validateTxSignature( + tx: Transaction, + hint: PqcIdentityHint, +): Promise { + let ed25519SignatureVerified = false + + if (tx.signature.type !== "ed25519") { + if (!tx.ed25519_signature) { + // PQC without co-signature: verify ownership via the prefetched identity record. + if (!hint) { + return { + hash: tx.hash, + valid: false, + reason: + "Transaction is missing ed25519 signature, and the PQC signer is not added as an identity. Please provide an ed25519 signature or add the PQC signer as an identity for " + + tx.content.from_ed25519_address, + } + } + ed25519SignatureVerified = await ucrypto.verify({ + algorithm: "ed25519", + message: new TextEncoder().encode(hint.address), + publicKey: hexToUint8Array(tx.content.from_ed25519_address), + signature: hexToUint8Array(hint.signature), + }) + } else { + // PQC with co-signature: verify the supplied ed25519 signature against tx.hash. + ed25519SignatureVerified = await ucrypto.verify({ + algorithm: "ed25519", + message: new TextEncoder().encode(tx.hash), + publicKey: hexToUint8Array(tx.content.from_ed25519_address), + signature: hexToUint8Array(tx.ed25519_signature), + }) + } + } else { + // ed25519 path: no separate ownership precheck. The single verify below IS the main signature. + ed25519SignatureVerified = true + } + + if (!ed25519SignatureVerified) { + return { + hash: tx.hash, + valid: false, + reason: "Ed25519 signature verification failed", + } + } + + const mainSignatureVerified = await ucrypto.verify({ + algorithm: tx.signature.type as SigningAlgorithm, + message: new TextEncoder().encode(tx.hash), + publicKey: hexToUint8Array(tx.content.from as string), + signature: hexToUint8Array(tx.signature.data), + }) + + return mainSignatureVerified + ? { hash: tx.hash, valid: true } + : { + hash: tx.hash, + valid: false, + reason: "Transaction signature verification failed", + } +} + +/** + * Full validation pipeline used by `Mempool.receive`: coherence first, then + * signature. Short-circuits on the first failure. + */ +export async function validateTx( + tx: Transaction, + hint: PqcIdentityHint, +): Promise { + const coherence = validateTxCoherence(tx) + if (!coherence.valid) return coherence + return await validateTxSignature(tx, hint) +} diff --git a/src/libs/blockchain/validation/txValidatorPool.ts b/src/libs/blockchain/validation/txValidatorPool.ts new file mode 100644 index 000000000..b475c8806 --- /dev/null +++ b/src/libs/blockchain/validation/txValidatorPool.ts @@ -0,0 +1,551 @@ +import { Worker } from "worker_threads" +import os from "os" +import { randomUUID } from "crypto" +import { + SigningAlgorithm, + Transaction, +} from "@kynesyslabs/demosdk/types" +import log from "src/utilities/logger" +import { getSharedState } from "@/utilities/sharedState" +import prefetchIdentities from "./prefetchIdentities" +import { validateTx } from "./txValidator" +import type { + IdentityHintMap, + TxValidationResult, + WorkerInitData, + WorkerRequest, + WorkerResponse, +} from "./types" +import type { signedObject } from "../../../../node_modules/@kynesyslabs/demosdk/build/encryption/unifiedCrypto" + +// Validate batches of every size go through the pool. The point of the pool +// is to keep crypto work off the main event loop, not to amortize IPC; a +// single ed25519/PQC verify on the main thread can block the loop for tens +// of ms and that's exactly what we want to avoid. The inline path stays as a +// safety net for "validate() called before start()", but with start() +// blocking on identity load it should never fire in practice. +const SMALL_BATCH_THRESHOLD = 1 +const PER_CHUNK_TIMEOUT_MS = 30_000 +const PER_REQUEST_TIMEOUT_MS = 30_000 +const STOP_TIMEOUT_DEFAULT_MS = 2_000 +const READY_TIMEOUT_DEFAULT_MS = 30_000 + +interface PendingRequest { + // One resolver type for all request kinds; public methods cast at + // dispatch time. Validated by message type in handleMessage. + resolve: (value: any) => void + reject: (err: Error) => void + timeout: ReturnType +} + +interface WorkerHandle { + worker: Worker + pending: Map + ready: Promise + markReady: () => void + markReadyFailed: (err: Error) => void + isReady: boolean +} + +function defaultWorkerCount(): number { + return Math.max(2, Math.min(8, os.cpus().length - 1)) +} + +function workerScriptUrl(): URL { + return new URL("./txValidatorWorker.ts", import.meta.url) +} + +/** + * Split items into `buckets` contiguous chunks, distributing remainder to the + * lower-indexed buckets. Reorder is a simple flatten. + */ +function chunkContiguous(items: T[], buckets: number): T[][] { + const out: T[][] = [] + if (buckets <= 0) return [items] + const base = Math.floor(items.length / buckets) + const rem = items.length % buckets + let cursor = 0 + for (let b = 0; b < buckets; b++) { + const size = base + (b < rem ? 1 : 0) + out.push(items.slice(cursor, cursor + size)) + cursor += size + } + return out +} + +/** + * Persistent worker pool for transaction validation. + * + * Public API: + * - `start(workerCount?)` — spawn workers (call once at boot). + * - `validate(txs)` — returns `TxValidationResult[]` in input order. + * - `stop(timeoutMs?)` — drain or terminate (call from gracefulShutdown). + * + * Behaviour: + * - `start()` blocks until every worker has signalled "ready", or rejects + * after `READY_TIMEOUT_DEFAULT_MS` if any worker fails to import. + * - Batches `< SMALL_BATCH_THRESHOLD` skip the pool and run inline. + * - Larger batches are split into N contiguous chunks (one per worker). + * - Each chunk dispatch is wrapped in a `PER_CHUNK_TIMEOUT_MS` defense timer. + * - Worker crashes (error event, non-zero exit, messageerror) are treated + * as fatal: in-flight requests reject and the node is sent SIGTERM so + * `gracefulShutdown` runs. We do NOT respawn — a crashing validator + * means a bug we want surfaced, not a degraded fallback. + * - If the pool is not started or has no workers, `validate()` falls back + * to inline execution and logs a warning. (Defensive only; `start()` + * succeeding implies all workers are ready.) + */ +export default class TxValidatorPool { + private static _instance: TxValidatorPool | null = null + private workers: WorkerHandle[] = [] + private started = false + private stopping = false + private nodeShutdownTriggered = false + + static getInstance(): TxValidatorPool { + if (!this._instance) this._instance = new TxValidatorPool() + return this._instance + } + + async start( + workerCount: number = defaultWorkerCount(), + readyTimeoutMs: number = READY_TIMEOUT_DEFAULT_MS, + ): Promise { + if (this.started) return + + // Workers need the node's master seed to call ucrypto.sign() as the + // node. Identity is loaded inside warmup() → preMainLoop() → + // identity.loadIdentity(); start() must be invoked after that. + const masterSeed = getSharedState.identity?.masterSeed + if (!masterSeed) { + throw new Error( + "TxValidatorPool.start() called before identity is loaded; getSharedState.identity.masterSeed is empty", + ) + } + const initData: WorkerInitData = { masterSeed } + + const startedAt = Date.now() + log.info( + `[TxValidatorPool] Spawning ${workerCount} workers; waiting up to ${readyTimeoutMs}ms for ready...`, + ) + + for (let slot = 0; slot < workerCount; slot++) { + this.spawnWorker(slot, initData) + } + + const allReady = Promise.all(this.workers.map(h => h.ready)) + let timeoutHandle: ReturnType | null = null + const timeout = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + const stragglers = this.workers + .map((h, i) => (h.isReady ? null : i)) + .filter((i): i is number => i !== null) + reject( + new Error( + `workers did not signal ready within ${readyTimeoutMs}ms (slots not ready: ${stragglers.join(", ")})`, + ), + ) + }, readyTimeoutMs) + }) + + try { + await Promise.race([allReady, timeout]) + } catch (err) { + // Tear down any workers we did spawn before propagating. + await this.terminateAll().catch(() => undefined) + this.workers = [] + throw err + } finally { + if (timeoutHandle) clearTimeout(timeoutHandle) + } + + this.started = true + log.info( + `[TxValidatorPool] Started with ${workerCount} workers in ${Date.now() - startedAt}ms`, + ) + } + + private spawnWorker(slot: number, initData: WorkerInitData): void { + let markReady!: () => void + let markReadyFailed!: (err: Error) => void + const ready = new Promise((resolve, reject) => { + markReady = resolve + markReadyFailed = reject + }) + + const worker = new Worker(workerScriptUrl(), { + // Inherit loader/runtime flags from the parent (tsx, tsconfig-paths, etc.) + execArgv: process.execArgv, + workerData: initData, + }) + const handle: WorkerHandle = { + worker, + pending: new Map(), + ready, + markReady: () => { + handle.isReady = true + markReady() + }, + markReadyFailed, + isReady: false, + } + worker.on("message", (msg: WorkerResponse) => + this.handleMessage(handle, msg), + ) + worker.on("error", err => + this.handleWorkerCrash( + slot, + handle, + err instanceof Error ? err : new Error(String(err)), + ), + ) + worker.on("exit", code => { + if (code !== 0 && !this.stopping) { + this.handleWorkerCrash( + slot, + handle, + new Error(`worker exited with code ${code}`), + ) + } + }) + worker.on("messageerror", err => + this.handleWorkerCrash( + slot, + handle, + new Error( + `messageerror (failed to deserialize parent→worker message): ${ + err instanceof Error ? err.message : String(err) + }`, + ), + ), + ) + this.workers[slot] = handle + } + + private handleMessage(handle: WorkerHandle, msg: WorkerResponse): void { + if (msg.type === "ready") { + handle.markReady() + return + } + if (msg.type === "fatal") { + log.error(`[TxValidatorPool] Worker fatal: ${msg.error}`) + if (msg.requestId) { + const pending = handle.pending.get(msg.requestId) + if (pending) { + handle.pending.delete(msg.requestId) + clearTimeout(pending.timeout) + pending.reject(new Error(`worker fatal: ${msg.error}`)) + } + } + return + } + const pending = handle.pending.get(msg.requestId) + if (!pending) { + log.warning( + `[TxValidatorPool] Result for unknown requestId ${msg.requestId}`, + ) + return + } + handle.pending.delete(msg.requestId) + clearTimeout(pending.timeout) + if (msg.type === "validateResult") { + pending.resolve(msg.results) + } else if (msg.type === "signResult") { + pending.resolve(msg.signedObject) + } else if (msg.type === "verifyResult") { + pending.resolve(msg.result) + } + } + + /** + * A worker should never crash. If one does, surface the cause loudly and + * tear the node down via SIGTERM so the existing gracefulShutdown path + * runs (DB/RPC/L2PS/etc. drain). Pre-ready crashes also reject the + * `start()` promise so the node fails to boot rather than running with + * a degraded validator. + */ + private handleWorkerCrash( + slot: number, + handle: WorkerHandle, + err: Error, + ): void { + if (this.stopping) return + log.error( + `[TxValidatorPool] Worker ${slot} crashed: ${err.message}`, + ) + if (err.stack) log.error(err.stack) + + for (const pending of handle.pending.values()) { + clearTimeout(pending.timeout) + pending.reject(new Error(`worker ${slot} crashed: ${err.message}`)) + } + handle.pending.clear() + + // If we're still booting, fail start() instead of triggering shutdown. + if (!handle.isReady) { + handle.markReadyFailed(err) + return + } + + this.triggerNodeShutdown(`worker ${slot} crashed: ${err.message}`) + } + + private triggerNodeShutdown(reason: string): void { + if (this.nodeShutdownTriggered) return + this.nodeShutdownTriggered = true + log.error( + `[TxValidatorPool] Initiating node shutdown via SIGTERM: ${reason}`, + ) + process.kill(process.pid, "SIGTERM") + } + + private async terminateAll(): Promise { + const terminations = this.workers + .filter((h): h is WorkerHandle => Boolean(h)) + .map(h => h.worker.terminate().catch(() => undefined)) + await Promise.all(terminations) + } + + async validate(txs: Transaction[]): Promise { + log.only("[TxValidatorPool] validate() called") + log.only(`[TxValidatorPool] txs length: ${txs.length}`) + + if (txs.length === 0) return [] + + if (!this.started || this.workers.length === 0) { + log.warning( + "[TxValidatorPool] validate() called but pool not started; using inline fallback", + ) + return this.validateInline(txs) + } + + // Small batches and unstarted pool both take the inline path. + if (txs.length < SMALL_BATCH_THRESHOLD) { + return this.validateInline(txs) + } + + const now = Date.now() + + const hints = await prefetchIdentities(txs) + const buckets = this.workers.length + const chunks = chunkContiguous(txs, buckets) + const dispatched = chunks.map(c => + c.length === 0 + ? Promise.resolve([] as TxValidationResult[]) + : this.dispatchChunk(c, hints), + ) + const settled = await Promise.allSettled(dispatched) + + const out: TxValidationResult[] = [] + for (let b = 0; b < buckets; b++) { + const result = settled[b] + const bucketTxs = chunks[b] + if (result.status === "fulfilled") { + out.push(...result.value) + continue + } + const reason = + result.reason instanceof Error + ? result.reason.message + : String(result.reason) + log.error( + `[TxValidatorPool] Chunk ${b} (${bucketTxs.length} txs) failed: ${reason}`, + ) + for (const tx of bucketTxs) { + out.push({ + hash: tx.hash, + valid: false, + reason: `validator pool error: ${reason}`, + }) + } + } + const end = Date.now() + log.only(`[TxValidatorPool] validate() took ${end - now}ms`) + return out + } + + /** + * Drop-in replacement for `ucrypto.sign(algorithm, data)` + * + * If the pool isn't started, throws — callers should treat unstarted + * pool as a programming error rather than silently signing on main. + */ + async sign( + algorithm: SigningAlgorithm, + data: Uint8Array, + ): Promise { + if (!this.started || this.workers.length === 0) { + throw new Error( + "TxValidatorPool.sign() called but pool is not started", + ) + } + const handle = this.pickWorker() + if (!handle) { + throw new Error("no workers available") + } + const requestId = randomUUID() + return this.dispatchOne(handle, requestId, { + type: "sign", + requestId, + algorithm, + data, + }) + } + + /** + * Drop-in replacement for `ucrypto.verify(signedObject)` + */ + async verify(signed: signedObject): Promise { + if (!this.started || this.workers.length === 0) { + throw new Error( + "TxValidatorPool.verify() called but pool is not started", + ) + } + const handle = this.pickWorker() + if (!handle) { + throw new Error("no workers available") + } + const requestId = randomUUID() + return this.dispatchOne(handle, requestId, { + type: "verify", + requestId, + signedObject: signed, + }) + } + + /** + * Generic single-request dispatcher used by sign() and verify(). Wraps + * the postMessage in the same pending/timeout machinery as dispatchChunk + * but for one request whose result type is known by the caller. + */ + private dispatchOne( + handle: WorkerHandle, + requestId: string, + req: WorkerRequest, + ): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + handle.pending.delete(requestId) + reject( + new Error( + `validator request ${requestId} timed out after ${PER_REQUEST_TIMEOUT_MS}ms`, + ), + ) + }, PER_REQUEST_TIMEOUT_MS) + handle.pending.set(requestId, { resolve, reject, timeout }) + try { + handle.worker.postMessage(req) + } catch (err) { + handle.pending.delete(requestId) + clearTimeout(timeout) + reject(err instanceof Error ? err : new Error(String(err))) + } + }) + } + + private async validateInline( + txs: Transaction[], + ): Promise { + const hints = await prefetchIdentities(txs) + return Promise.all( + txs.map(tx => validateTx(tx, hints[tx.hash] ?? null)), + ) + } + + private dispatchChunk( + txs: Transaction[], + hints: IdentityHintMap, + ): Promise { + // Round-robin worker selection. We allow each worker to hold multiple + // in-flight requests because validation throughput per worker is + // bounded by ucrypto.verify, not by request count. + const handle = this.pickWorker() + if (!handle) { + return Promise.reject(new Error("no workers available")) + } + + const requestId = randomUUID() + // Send only the hints relevant to this chunk to keep IPC payloads small. + const subHints: IdentityHintMap = {} + for (const tx of txs) { + if (hints[tx.hash] !== undefined) { + subHints[tx.hash] = hints[tx.hash] + } + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + handle.pending.delete(requestId) + reject( + new Error( + `validator request ${requestId} timed out after ${PER_CHUNK_TIMEOUT_MS}ms`, + ), + ) + }, PER_CHUNK_TIMEOUT_MS) + handle.pending.set(requestId, { resolve, reject, timeout }) + const req: WorkerRequest = { + type: "validate", + requestId, + txs, + identityHints: subHints, + } + try { + handle.worker.postMessage(req) + } catch (err) { + handle.pending.delete(requestId) + clearTimeout(timeout) + reject(err instanceof Error ? err : new Error(String(err))) + } + }) + } + + private rrIndex = 0 + private pickWorker(): WorkerHandle | null { + if (this.workers.length === 0) return null + // Walk up to N slots in case some are temporarily empty (between + // crash + respawn). Returns the first non-empty handle. + for (let attempt = 0; attempt < this.workers.length; attempt++) { + const slot = this.rrIndex % this.workers.length + this.rrIndex = (this.rrIndex + 1) % this.workers.length + const handle = this.workers[slot] + if (handle) return handle + } + return null + } + + async stop(timeoutMs: number = STOP_TIMEOUT_DEFAULT_MS): Promise { + if (!this.started) return + this.stopping = true + + // Best-effort drain: wait for in-flight requests, then terminate. + const drainPromises: Promise[] = [] + for (const handle of this.workers) { + if (!handle) continue + try { + handle.worker.postMessage({ type: "shutdown" }) + } catch { + /* worker may already be dead */ + } + drainPromises.push( + new Promise(resolve => { + const start = Date.now() + const tick = () => { + if (handle.pending.size === 0) return resolve() + if (Date.now() - start >= timeoutMs) return resolve() + setTimeout(tick, 50) + } + tick() + }), + ) + } + await Promise.all(drainPromises) + + await this.terminateAll() + + this.workers = [] + this.started = false + this.stopping = false + TxValidatorPool._instance = null + log.info("[TxValidatorPool] Stopped") + } +} diff --git a/src/libs/blockchain/validation/txValidatorWorker.ts b/src/libs/blockchain/validation/txValidatorWorker.ts new file mode 100644 index 000000000..8d0bff13c --- /dev/null +++ b/src/libs/blockchain/validation/txValidatorWorker.ts @@ -0,0 +1,105 @@ +import { parentPort, workerData } from "worker_threads" +import { validateTx } from "./txValidator" +import type { + TxValidationResult, + WorkerInitData, + WorkerRequest, + WorkerResponse, +} from "./types" +// Same path-bypass as txValidator.ts: avoid the encryption package index so we +// don't transitively load zK (ffjavascript → web-worker), which crashes inside +// worker_threads. See the long comment in txValidator.ts. +import { + unifiedCrypto as ucrypto, +} from "../../../../node_modules/@kynesyslabs/demosdk/build/encryption/unifiedCrypto.js" + +if (!parentPort) { + throw new Error("txValidatorWorker must be loaded as a worker_threads worker") +} + +const port = parentPort + +// Surfaces structured-clone failures on inbound messages. The worker is still +// alive but a request was lost — exit non-zero so the parent treats it as a +// crash and tears the node down. +port.on("messageerror", err => { + console.error("[txValidatorWorker] messageerror:", err) + process.exit(1) +}) + +port.on("message", async (msg: WorkerRequest) => { + if (msg.type === "shutdown") { + // Cooperative exit. Pool waits for in-flight requests to drain before + // sending shutdown, so it's safe to terminate here. + process.exit(0) + return + } + + try { + if (msg.type === "validate") { + const results: TxValidationResult[] = [] + for (const tx of msg.txs) { + results.push( + await validateTx(tx, msg.identityHints[tx.hash] ?? null), + ) + } + port.postMessage({ + type: "validateResult", + requestId: msg.requestId, + results, + } satisfies WorkerResponse) + return + } + + if (msg.type === "sign") { + const signedObject = await ucrypto.sign(msg.algorithm, msg.data) + port.postMessage({ + type: "signResult", + requestId: msg.requestId, + signedObject, + } satisfies WorkerResponse) + return + } + + if (msg.type === "verify") { + const result = await ucrypto.verify(msg.signedObject) + port.postMessage({ + type: "verifyResult", + requestId: msg.requestId, + result, + } satisfies WorkerResponse) + return + } + } catch (err) { + port.postMessage({ + type: "fatal", + requestId: (msg as { requestId?: string }).requestId, + error: err instanceof Error ? err.message : String(err), + } satisfies WorkerResponse) + } +}) + +// Boot identity from the master seed so ucrypto.sign() works inside this +// worker. The pool blocks start() until every worker has signaled ready, +// which only happens after this completes. A failure here propagates up via +// the unhandledRejection path → process exits non-zero → pool treats it as +// a startup crash. +async function init(): Promise { + const init = workerData as WorkerInitData | undefined + if (!init?.masterSeed) { + throw new Error( + "txValidatorWorker: workerData.masterSeed missing; cannot derive node identity", + ) + } + await ucrypto.ensureSeed(init.masterSeed) + await ucrypto.generateAllIdentities(init.masterSeed) +} + +await init() + +// Signal readiness only after the module has fully loaded (all imports +// resolved, identities generated, listeners registered). The pool blocks +// start() until every worker emits this, so a worker that fails to import +// or fails identity setup surfaces as a startup timeout / crash instead of +// a silent hang on the first request. +port.postMessage({ type: "ready" } satisfies WorkerResponse) diff --git a/src/libs/blockchain/validation/types.ts b/src/libs/blockchain/validation/types.ts new file mode 100644 index 000000000..2aecd2cac --- /dev/null +++ b/src/libs/blockchain/validation/types.ts @@ -0,0 +1,71 @@ +import type { SavedPqcIdentity } from "../../../model/entities/types/IdentityTypes" +import type { + SigningAlgorithm, + Transaction, +} from "@kynesyslabs/demosdk/types" +import type { signedObject } from "../../../../node_modules/@kynesyslabs/demosdk/build/encryption/unifiedCrypto" + +export interface TxValidationResult { + hash: string + valid: boolean + reason?: string +} + +export type PqcIdentityHint = SavedPqcIdentity | null + +/** Keyed by tx.hash. Only populated for PQC txs without an ed25519 co-signature. */ +export type IdentityHintMap = Record + +/** Parent → worker messages. */ +export type WorkerRequest = + | { + type: "validate" + requestId: string + txs: Transaction[] + identityHints: IdentityHintMap + } + | { + type: "sign" + requestId: string + algorithm: SigningAlgorithm + data: Uint8Array + } + | { + type: "verify" + requestId: string + signedObject: signedObject + } + | { type: "shutdown" } + +/** Worker → parent messages. */ +export type WorkerResponse = + | { + type: "validateResult" + requestId: string + results: TxValidationResult[] + } + | { + type: "signResult" + requestId: string + signedObject: signedObject + } + | { + type: "verifyResult" + requestId: string + result: boolean + } + | { + type: "fatal" + requestId?: string + error: string + } + | { type: "ready" } + +/** Data passed via Worker `workerData` at spawn time. */ +export interface WorkerInitData { + /** + * The node's master seed. Workers call ucrypto.ensureSeed + + * generateAllIdentities so they can sign as the node. + */ + masterSeed: Uint8Array +} diff --git a/src/libs/communications/broadcastManager.ts b/src/libs/communications/broadcastManager.ts index 6bb85cb4c..71d4dfdb2 100644 --- a/src/libs/communications/broadcastManager.ts +++ b/src/libs/communications/broadcastManager.ts @@ -6,6 +6,7 @@ import { syncBlock } from "../blockchain/routines/Sync" import { RPCRequest } from "@kynesyslabs/demosdk/types" import { Waiter } from "@/utilities/waiter" import { getSharedState } from "@/utilities/sharedState" +import SecretaryManager from "../consensus/v2/types/secretaryManager" /** * Manages the broadcasting of messages to the network @@ -70,6 +71,7 @@ export class BroadcastManager { * @param block The new block received */ static async handleNewBlock(sender: string, block: Block) { + log.debug("handleNewBlock called with block: " + block.number) const peerman = PeerManager.getInstance() if (Waiter.isWaiting(Waiter.keys.SYNC_WAIT_FOR_BLOCK)) { @@ -81,7 +83,7 @@ export class BroadcastManager { return { result: 200, message: "Block received while waiting for next block", - syncData: PeerManager.getInstance().ourSyncDataString, + syncData: peerman.ourSyncDataString, } } @@ -89,7 +91,7 @@ export class BroadcastManager { return { result: 200, message: "Cannot handle new block. Node is not initialized", - syncData: PeerManager.getInstance().ourSyncDataString, + syncData: peerman.ourSyncDataString, } } @@ -103,6 +105,28 @@ export class BroadcastManager { } } + // If we signed the block, exit + if (block.validation_data.signatures[getSharedState.publicKeyHex]) { + log.only("Block is already signed by us, ignoring it") + return { + result: 200, + message: "Block is already signed by us, ignoring it", + syncData: peerman.ourSyncDataString, + } + } + + // If block is greater than our last block + 1, exit + if (block.number > getSharedState.lastBlockNumber + 1) { + log.only("Block is greater than our last block + 1, ignoring it") + + return { + result: 200, + message: + "Block is greater than our last block + 1, ignoring it", + syncData: peerman.ourSyncDataString, + } + } + // check if we already have the block const existing = await Chain.getBlockByHash(block.hash) if (existing) { @@ -113,6 +137,19 @@ export class BroadcastManager { } } + // check if we're in the consensus for received block + const manager = SecretaryManager.getInstance(block.number) + + if (manager) { + log.debug("Received block while in consensus") + + return { + result: 200, + message: "Cannot process block, still in consensus", + syncData: peerman.ourSyncDataString, + } + } + const peer = peerman.getPeer(sender) const res = await syncBlock(block, peer) @@ -180,6 +217,10 @@ export class BroadcastManager { * @param syncData The sync data to update */ static async handleUpdatePeerSyncData(sender: string, syncData: string) { + log.debug( + "handleUpdatePeerSyncData called with syncData: " + + JSON.stringify(syncData), + ) const peerman = PeerManager.getInstance() const ePeer = peerman.getPeer(sender) diff --git a/src/libs/consensus/v2/PoRBFT.ts b/src/libs/consensus/v2/PoRBFT.ts index 362019057..511a3518b 100644 --- a/src/libs/consensus/v2/PoRBFT.ts +++ b/src/libs/consensus/v2/PoRBFT.ts @@ -4,7 +4,7 @@ import Mempool from "src/libs/blockchain/mempool" import Block from "src/libs/blockchain/block" import Chain from "src/libs/blockchain/chain" import { getSharedState } from "src/utilities/sharedState" -import { Peer } from "src/libs/peer" +import { Peer, PeerManager } from "src/libs/peer" import log from "src/utilities/logger" import { mergeMempools } from "./routines/mergeMempools" import { createBlock } from "./routines/createBlock" @@ -16,6 +16,7 @@ import HandleGCR from "src/libs/blockchain/gcr/handleGCR" import L2PSConsensus from "@/libs/l2ps/L2PSConsensus" import { DTRManager } from "@/libs/network/dtr/dtrmanager" import { BroadcastManager } from "@/libs/communications/broadcastManager" +import { fastSync, waitForPeerStatus } from "@/libs/blockchain/routines/Sync" /* INFO # Semaphore system @@ -63,17 +64,54 @@ export async function consensusRoutine(): Promise { let tempMempool: Transaction[] = [] try { + log.only("[consensusRoutine] Initializing the consensus state") await initializeConsensusState() + const { latestChainBlock, ourLatestBlock } = await fastSync( + [], + "consensusRoutine", + ) + + if (latestChainBlock !== ourLatestBlock) { + log.error( + "[consensusRoutine] Latest chain block is not equal to our latest block, exiting", + ) + return + } + const peersReady = await waitForPeerStatus() + if (!peersReady) { + log.warn( + "[consensusRoutine] Peers ahead of us, aborting this round", + ) + return + } + + log.only( + "ALL PEERS: " + + JSON.stringify(PeerManager.getInstance().getAll(), null, 2), + ) + log.only( + "Ready peers: " + + JSON.stringify( + PeerManager.getInstance() + .getPeers() + .filter(p => p.sync.status && p.status.ready) + .map(p => p.connection.string), + ), + ) + + log.only("[consensusRoutine] Consensus state initialized") + // await fastSync([], "consensusRoutine") // Initialize the consensus state and check if the local node is in the shard // INFO: We won't use the shard returned by initializeShard // as it can change through the consensus routine // INFO: CONSENSUS ACTION 1: Initialize the shard await initializeShard(blockRef) - log.debug(`Forgin block: ${manager.shard.blockRef}`) - log.debug("[consensusRoutine] We are in the shard, creating the block") - log.debug( + preventForgingEnded(blockRef) + log.only(`Forgin block: ${manager.shard.blockRef}`) + log.only("[consensusRoutine] We are in the shard, creating the block") + log.only( `[consensusRoutine] shard: ${JSON.stringify( manager.shard.members.map(m => m.connection.string), null, @@ -84,20 +122,21 @@ export async function consensusRoutine(): Promise { // INFO: Broadcast our validation phase to the secretary await updateValidatorPhase(1, blockRef) - + preventForgingEnded(blockRef) // synchronize and average the time // NOTE: Instead of averaging the time, we'll use the secretary timestamp // await synchronizeAndAverageTime(shard) // INFO: CONSENSUS ACTION 2: Merge and order the mempools - log.debug("[consensusRoutine] Merging and ordering the mempools...") + log.only("[consensusRoutine] Merging and ordering the mempools...") tempMempool = await mergeAndOrderMempools( manager.shard.members, manager.shard.blockRef, ) + preventForgingEnded(blockRef) - log.debug(`[consensusRoutine] Our mempool size: ${tempMempool.length}`) - log.debug( + log.only(`[consensusRoutine] Our mempool size: ${tempMempool.length}`) + log.only( `[consensusRoutine] Our mempool: ${JSON.stringify( tempMempool.map(tx => tx.hash), null, @@ -105,57 +144,6 @@ export async function consensusRoutine(): Promise { )}`, ) - // INFO: CONSENSUS ACTION 3: Merge the peerlist (skipped) - // Merge the peerlist - const peerlist = [] - // await mergePeerlistAndWait(shard) - - // INFO: CONSENSUS ACTION 4: Apply the GCR operations to the state before forging the block - /** - * Here we apply the GCR operations to the state before forging the block - * so that the GCR hash is included in the block. - * A list of successful and failed GCR operations is returned. - * NOTE A mandatory validator status is updated to reflect that the GCR operations have been applied - * */ - // ? The following line could be outdated once we use the GCREdit stuff - // await applyGCRForNewBlock(mempool) - - // Applying the GCREdits and see if everything is consistent - const { successfulTxs: localSuccessfulTxs, failedTxs: localFailedTxs } = - await applyGCREditsFromMergedMempool(tempMempool) - successfulTxs = successfulTxs.concat(localSuccessfulTxs) - failedTxs = failedTxs.concat(localFailedTxs) - log.debug(`[consensusRoutine] Successful Txs: ${successfulTxs.length}`) - log.debug(`[consensusRoutine] Failed Txs: ${failedTxs.length}`) - if (failedTxs.length > 0) { - log.debug( - "[consensusRoutine] Failed Txs found, pruning the mempool", - ) - // Prune the mempool of the failed txs - // NOTE The mempool should now be updated with only the successful txs - for (const tx of failedTxs) { - log.debug(`Failed tx: ${tx}`) - await Mempool.removeTransactionsByHashes([tx]) - } - } - - // INFO: CONSENSUS ACTION 4b: Apply pending L2PS proofs to L1 state - // L2PS proofs contain GCR edits that modify L1 balances (unified state architecture) - const l2psResult = await L2PSConsensus.applyPendingProofs( - blockRef, - false, - ) - if (l2psResult.proofsApplied > 0) { - log.info( - `[consensusRoutine] Applied ${l2psResult.proofsApplied} L2PS proofs with ${l2psResult.totalEditsApplied} GCR edits`, - ) - } - if (l2psResult.proofsFailed > 0) { - log.warning( - `[consensusRoutine] ${l2psResult.proofsFailed} L2PS proofs failed verification`, - ) - } - // REVIEW Re-merge the mempools anyway to get the correct mempool from the whole shard // const mempool = await mergeAndOrderMempools(manager.shard.members) @@ -170,6 +158,7 @@ export async function consensusRoutine(): Promise { "[CONSENSUS ROUTINE] Secretary block timestamp not received yet, requesting it ...", ) const blockTimestamp = await manager.getSecretaryBlockTimestamp() + preventForgingEnded(blockRef) if (blockTimestamp) { getSharedState.lastConsensusTime = blockTimestamp @@ -182,7 +171,8 @@ export async function consensusRoutine(): Promise { } // INFO: CONSENSUS ACTION 5: Forge the block - const block = await forgeBlock(tempMempool, peerlist) // NOTE The GCR hash is calculated here and added to the block + const block = await forgeBlock(tempMempool, []) // NOTE The GCR hash is calculated here and added to the block + preventForgingEnded(blockRef) // REVIEW Set last consensus time to the current block timestamp getSharedState.lastConsensusTime = block.content.timestamp @@ -191,21 +181,85 @@ export async function consensusRoutine(): Promise { // Check if the block is valid if (isBlockValid(pro, manager.shard.members.length)) { + // INFO: CONSENSUS ACTION 4: Apply the GCR operations to the state before forging the block + /** + * Here we apply the GCR operations to the state before forging the block + * so that the GCR hash is included in the block. + * A list of successful and failed GCR operations is returned. + * NOTE A mandatory validator status is updated to reflect that the GCR operations have been applied + * */ + // ? The following line could be outdated once we use the GCREdit stuff + // await applyGCRForNewBlock(mempool) + + // Applying the GCREdits and see if everything is consistent + const { + successfulTxs: localSuccessfulTxs, + failedTxs: localFailedTxs, + } = await applyGCREditsFromMergedMempool(tempMempool) + BroadcastManager.broadcastNewBlock(block) + + successfulTxs = successfulTxs.concat(localSuccessfulTxs) + failedTxs = failedTxs.concat(localFailedTxs) + log.debug( + `[consensusRoutine] Successful Txs: ${successfulTxs.length}`, + ) + log.debug(`[consensusRoutine] Failed Txs: ${failedTxs.length}`) + if (failedTxs.length > 0) { + log.debug( + "[consensusRoutine] Failed Txs found, pruning the mempool", + ) + // Prune the mempool of the failed txs + // NOTE The mempool should now be updated with only the successful txs + const pruneStart = Date.now() + for (const tx of failedTxs) { + log.debug(`Failed tx: ${tx}`) + await Mempool.removeTransactionsByHashes([tx]) + } + const pruneEnd = Date.now() + log.only( + `[consensusRoutine] Prune took ${pruneEnd - pruneStart}ms`, + ) + } + + // INFO: CONSENSUS ACTION 4b: Apply pending L2PS proofs to L1 state + // L2PS proofs contain GCR edits that modify L1 balances (unified state architecture) + const l2psStart = Date.now() + const l2psResult = await L2PSConsensus.applyPendingProofs( + blockRef, + false, + ) + const l2psEnd = Date.now() + log.only(`[consensusRoutine] L2PS took ${l2psEnd - l2psStart}ms`) + + if (l2psResult.proofsApplied > 0) { + log.info( + `[consensusRoutine] Applied ${l2psResult.proofsApplied} L2PS proofs with ${l2psResult.totalEditsApplied} GCR edits`, + ) + } + if (l2psResult.proofsFailed > 0) { + log.warning( + `[consensusRoutine] ${l2psResult.proofsFailed} L2PS proofs failed verification`, + ) + } log.debug( "[consensusRoutine] [result] Block is valid with " + pro + " votes", ) + const finalizeStart = Date.now() await finalizeBlock(block, pro) - + const finalizeEnd = Date.now() + log.only( + `[consensusRoutine] Finalize took ${finalizeEnd - finalizeStart}ms`, + ) + log.only("[consensusRoutine] Block finalized") // REVIEW: Should we await this? // REVIEW: All nodes broadcast the block for redundancy // if (manager.checkIfWeAreSecretary()) { - BroadcastManager.broadcastNewBlock(block) // } // INFO: Release DTR transaction relay waiter - await DTRManager.releaseDTRWaiter(block) + DTRManager.releaseDTRWaiter(block) } else { log.error( `[consensusRoutine] [result] Block is not valid with ${pro} votes`, @@ -216,8 +270,35 @@ export async function consensusRoutine(): Promise { ) } + // // Check if the block is valid + // if (isBlockValid(pro, manager.shard.members.length)) { + // log.debug( + // "[consensusRoutine] [result] Block is valid with " + + // pro + + // " votes", + // ) + // await finalizeBlock(block, pro) + + // // REVIEW: Should we await this? + // // REVIEW: All nodes broadcast the block for redundancy + // // if (manager.checkIfWeAreSecretary()) { + // BroadcastManager.broadcastNewBlock(block) + // // } + + // // INFO: Release DTR transaction relay waiter + // await DTRManager.releaseDTRWaiter(block) + // } else { + // log.error( + // `[consensusRoutine] [result] Block is not valid with ${pro} votes`, + // ) + // // Raising an error to rollback the GCREdits + // throw new BlockInvalidError( + // `[consensusRoutine] [result] Block is not valid with ${pro} votes`, + // ) + // } + // INFO: CONSENSUS ACTION 7: End the consensus routine - await updateValidatorPhase(7, blockRef) + // await updateValidatorPhase(7, blockRef) } catch (error) { if ( error instanceof NotInShardError || @@ -269,13 +350,13 @@ export async function consensusRoutine(): Promise { } finally { // INFO: If there was a relayed tx past finalize block step, release if (DTRManager.poolSize > 0) { - await DTRManager.releaseDTRWaiter() + DTRManager.releaseDTRWaiter() } cleanupConsensusState() manager.endConsensusRoutine() - log.debug("[consensusRoutine] Consensus routine ended") + log.only("[consensusRoutine] Consensus routine ended") } } @@ -318,6 +399,13 @@ async function initializeShard(blockRef: number): Promise { await getCommonValidatorSeed() const manager = SecretaryManager.getInstance(blockRef) + + if (!manager) { + throw new ForgingEndedError( + "Secretary Manager instance for this block has been deleted", + ) + } + return await manager.initializeShard(commonValidatorSeed, lastBlockNumber) } @@ -355,6 +443,8 @@ async function mergeAndOrderMempools( ): Promise<(Transaction & { reference_block: number })[]> { // Fetch mempool, check chain for executed txs. const preMempool = await Mempool.getMempool(blockRef) + + log.only(`[mergeAndOrderMempools] Pre mempool: ${preMempool.length} txs`) const preHashes = preMempool.map(tx => tx.hash) const preExisting = preHashes.length > 0 @@ -367,17 +457,23 @@ async function mergeAndOrderMempools( await updateValidatorPhase(3, blockRef) const postMempool = await Mempool.getMempool(blockRef) + log.only(`[mergeAndOrderMempools] Post mempool: ${postMempool.length} txs`) const preChecked = new Set(preHashes) const newHashes = postMempool .map(tx => tx.hash) .filter(h => !preChecked.has(h)) + log.only(`[mergeAndOrderMempools] New hashes: ${newHashes.length}`) const newlyExisting = newHashes.length > 0 ? await Chain.getExistingTransactionHashes(newHashes) : new Set() const existingHashes = preExisting.union(newlyExisting) + log.only(`[mergeAndOrderMempools] Existing hashes: ${existingHashes.size}`) const finalMempool = postMempool.filter(tx => !existingHashes.has(tx.hash)) + log.only( + `[mergeAndOrderMempools] Final mempool: ${finalMempool.length} txs`, + ) // INFO: Remove existing txs from mempool await Mempool.removeTransactionsByHashes(Array.from(existingHashes)) @@ -385,15 +481,20 @@ async function mergeAndOrderMempools( // Log transaction type breakdown const typeCounts: Record = {} for (const tx of finalMempool) { + // INFO: Update tx block number to this consensus' block number + tx.blockNumber = blockRef + const txType = tx.content.type ?? "unknown" typeCounts[txType] = (typeCounts[txType] || 0) + 1 } - log.debug( + + log.only( `[mergeAndOrderMempools] Final mempool: ${finalMempool.length} txs ` + `(removed ${existingHashes.size}: pre-merge ${preExisting.size}, delta ${newlyExisting.size})`, ) + for (const [type, count] of Object.entries(typeCounts)) { - log.debug(`[mergeAndOrderMempools] ${type}: ${count}`) + log.only(`[mergeAndOrderMempools] ${type}: ${count}`) } return finalMempool @@ -522,7 +623,7 @@ async function forgeBlock( // await updateValidatorStatus("forgedBlock", true, false, true) // Using the secretary to update the local statuses - await updateValidatorPhase(5, block.number) + // await updateValidatorPhase(5, block.number) return block } @@ -537,14 +638,19 @@ async function voteOnBlock( block: Block, shard: Peer[], ): Promise<[number, number]> { - log.debug( + const now = Date.now() + log.only( `[consensusRoutine] Broadcasting block hash to the shard: ${block.hash}`, ) const [pro, con] = await broadcastBlockHash(block, shard) // await updateValidatorStatus("votedForBlock", true, false, true) // Using the secretary to update the local statuses await updateValidatorPhase(6, block.number) - log.debug(`[consensusRoutine] Votes:\nPro: ${pro}\nCon: ${con}`) + log.only(`[consensusRoutine] Votes:\nPro: ${pro}\nCon: ${con}`) + const end = Date.now() + log.only( + `[consensusRoutine] Time taken: ${(end - now) / 1000}s for voting on the block`, + ) return [pro, con] } diff --git a/src/libs/consensus/v2/routines/broadcastBlockHash.ts b/src/libs/consensus/v2/routines/broadcastBlockHash.ts index 435e5cb00..1bcb156ff 100644 --- a/src/libs/consensus/v2/routines/broadcastBlockHash.ts +++ b/src/libs/consensus/v2/routines/broadcastBlockHash.ts @@ -5,6 +5,7 @@ import { Peer } from "src/libs/peer" import { getSharedState } from "src/utilities/sharedState" import log from "src/utilities/logger" import { hexToUint8Array, ucrypto } from "@kynesyslabs/demosdk/encryption" +import TxValidatorPool from "@/libs/blockchain/validation/txValidatorPool" export async function broadcastBlockHash( block: Block, @@ -34,6 +35,7 @@ export async function broadcastBlockHash( ), // REVIEW We should wait a little if the call returns false as the node is not in the consensus loop yet and in general for all consensus_routine calls ) } + // See manageConsensusRoutine.ts for more details on the response format and mechanism for (const promise of promises) { // Work asynchronously @@ -65,7 +67,7 @@ export async function broadcastBlockHash( const signatureVerificationPromises = Object.entries( incomingSignatures, ).map(async ([identity, signature]) => { - const isValid = await ucrypto.verify({ + const isValid = await TxValidatorPool.getInstance().verify({ algorithm: getSharedState.signingAlgorithm, message: new TextEncoder().encode(block.hash), signature: hexToUint8Array(signature), @@ -115,6 +117,24 @@ export async function broadcastBlockHash( ) if (response.extra.ourBlock) { + const theirTxHashes: string[] = + response.extra.ourBlock.txHashes ?? + response.extra.ourBlock.ordered_transactions ?? + [] + const ourTxHashes: string[] = + getSharedState.candidateBlock.content + .ordered_transactions + + const theirSet = new Set(theirTxHashes) + const ourSet = new Set(ourTxHashes) + + const missingFromUs = theirTxHashes.filter( + h => !ourSet.has(h), + ) + const missingFromThem = ourTxHashes.filter( + h => !theirSet.has(h), + ) + log.error( "Their block: " + JSON.stringify(response.extra.ourBlock, null, 2), @@ -129,17 +149,19 @@ export async function broadcastBlockHash( timestamp: getSharedState.candidateBlock.content .timestamp, - txCount: - getSharedState.candidateBlock.content - .ordered_transactions.length, - txHashes: - getSharedState.candidateBlock.content - .ordered_transactions, + txCount: ourTxHashes.length, + txHashes: ourTxHashes, }, null, 2, ), ) + log.error( + `[broadcastBlockHash] Missing from us (${missingFromUs.length}): ${JSON.stringify(missingFromUs)}`, + ) + log.error( + `[broadcastBlockHash] Missing from them (${missingFromThem.length}): ${JSON.stringify(missingFromThem)}`, + ) } con++ } diff --git a/src/libs/consensus/v2/routines/createBlock.ts b/src/libs/consensus/v2/routines/createBlock.ts index 4e82142b6..46d4ed850 100644 --- a/src/libs/consensus/v2/routines/createBlock.ts +++ b/src/libs/consensus/v2/routines/createBlock.ts @@ -8,6 +8,7 @@ import Peer from "src/libs/peer/Peer" import hashGCRTables from "src/libs/blockchain/gcr/gcr_routines/hashGCR" import getCommonValidatorSeed from "./getCommonValidatorSeed" import { ucrypto, uint8ArrayToHex } from "@kynesyslabs/demosdk/encryption" +import TxValidatorPool from "@/libs/blockchain/validation/txValidatorPool" import { serializeBlockContent } from "@/forks" export async function createBlock( @@ -41,7 +42,7 @@ export async function createBlock( block.hash = Hashing.sha256(serializeBlockContent(block.content, blockNumber)) // Signing the block and adding the signature to the block validation data - const blockSignature = await ucrypto.sign( + const blockSignature = await TxValidatorPool.getInstance().sign( getSharedState.signingAlgorithm, new TextEncoder().encode(block.hash), ) diff --git a/src/libs/consensus/v2/routines/getCommonValidatorSeed.ts b/src/libs/consensus/v2/routines/getCommonValidatorSeed.ts index a3317def7..f1b20ae7c 100644 --- a/src/libs/consensus/v2/routines/getCommonValidatorSeed.ts +++ b/src/libs/consensus/v2/routines/getCommonValidatorSeed.ts @@ -65,7 +65,17 @@ export default async function getCommonValidatorSeed( const blockCount = 3 if (!lastBlock) { - lastBlock = await Chain.getLastBlock() + for (let attempt = 1; attempt <= 3; attempt++) { + lastBlock = await Chain.getLastBlock() + if (lastBlock) break + if (attempt < 3) await sleep(100) + } + } + + if (!lastBlock) { + throw new Error( + `getCommonValidatorSeed: Chain.getLastBlock() returned null after 3 retries (sharedState.lastBlockNumber=${getSharedState.lastBlockNumber})`, + ) } const lastBlockNumber = lastBlock.number diff --git a/src/libs/consensus/v2/routines/getShard.ts b/src/libs/consensus/v2/routines/getShard.ts index 1343f9ea5..63c8abb44 100644 --- a/src/libs/consensus/v2/routines/getShard.ts +++ b/src/libs/consensus/v2/routines/getShard.ts @@ -3,7 +3,7 @@ import { Peer } from "src/libs/peer" import Alea from "alea" import { getSharedState } from "src/utilities/sharedState" import log from "src/utilities/logger" -import Chain from "src/libs/blockchain/chain" +import { getLastBlockSigners } from "@/libs/blockchain/chainBlocks" import GCR from "src/libs/blockchain/gcr/gcr" import type { Validators } from "src/model/entities/Validators" @@ -28,9 +28,37 @@ export function __resetValidatorCache(): void { */ export default async function getShard(seed: string): Promise { // ! we need to get the peers from the last 3 blocks too - const allPeers = await PeerManager.getInstance().getOnlinePeers() + const peerman = PeerManager.getInstance() + const allPeers = await peerman.getOnlinePeers() const peers = allPeers.filter( - peer => peer.status.online && peer.sync.status && Math.abs(peer.sync.block - getSharedState.lastBlockNumber) <= 1, + peer => + // peer.status.online && + // peer.sync.status && + peer.sync.block === getSharedState.lastBlockNumber && + peer.sync.block_hash === getSharedState.lastBlockHash, + ) + + const lastBlockSigners = await getLastBlockSigners() + + const initialPeers = new Set(peers.map(peer => peer.identity)) + log.only( + "Initial peers: " + JSON.stringify(Array.from(initialPeers), null, 2), + ) + for (const signer of lastBlockSigners) { + const peer = peerman.getPeer(signer) + + if (peer && !initialPeers.has(signer)) { + peers.push(peer) + log.only("Adding peer: " + signer + " to the shard (is working)") + } + } + log.only( + "Final peers: " + + JSON.stringify( + peers.map(peer => peer.identity), + null, + 2, + ), ) // Fetch active validators from DB at the current block, with a diff --git a/src/libs/consensus/v2/routines/manageProposeBlockHash.ts b/src/libs/consensus/v2/routines/manageProposeBlockHash.ts index 8c0b83fff..83093709a 100644 --- a/src/libs/consensus/v2/routines/manageProposeBlockHash.ts +++ b/src/libs/consensus/v2/routines/manageProposeBlockHash.ts @@ -9,6 +9,7 @@ import { hexToUint8Array, ucrypto } from "@kynesyslabs/demosdk/encryption" import PeerManager from "@/libs/peer/PeerManager" import getCommonValidatorSeed from "./getCommonValidatorSeed" import getShard from "./getShard" +import TxValidatorPool from "@/libs/blockchain/validation/txValidatorPool" export default async function manageProposeBlockHash( blockHash: string, @@ -54,11 +55,12 @@ export default async function manageProposeBlockHash( "[manageProposeBlockHash] Candidate block not formed: refusing the block hash", ) - response.result = 401 + response.result = 404 response.response = getSharedState.publicKeyHex response.extra = "Candidate block not formed" return response } + const ourCandidateHash = getSharedState.candidateBlock.hash if (ourCandidateHash === blockHash) { log.info( @@ -74,7 +76,7 @@ export default async function manageProposeBlockHash( let isValid = false try { - isValid = await ucrypto.verify({ + isValid = await TxValidatorPool.getInstance().verify({ algorithm: getSharedState.signingAlgorithm, message: new TextEncoder().encode( getSharedState.candidateBlock.hash, diff --git a/src/libs/consensus/v2/routines/mergeMempools.ts b/src/libs/consensus/v2/routines/mergeMempools.ts index 48d93d80a..2e31fbce2 100644 --- a/src/libs/consensus/v2/routines/mergeMempools.ts +++ b/src/libs/consensus/v2/routines/mergeMempools.ts @@ -8,7 +8,7 @@ import { } from "@kynesyslabs/demosdk/types" import { getSharedState } from "@/utilities/sharedState" -const PEER_CALL_TIMEOUT_MS = 15_000 +const PEER_CALL_TIMEOUT_MS = 10_000 function withTimeout( promise: Promise, @@ -17,16 +17,16 @@ function withTimeout( ): Promise { let timeoutId: ReturnType | undefined const timeoutPromise = new Promise(resolve => { - timeoutId = setTimeout( - () => - resolve({ - result: 504, - response: "mergeMempools peer timeout", - require_reply: false, - extra: peer.identity, - }), - ms, - ) + timeoutId = setTimeout(() => { + log.error(`[withTimeout] Peer ${peer.connection.string} timed out`) + + return resolve({ + result: 504, + response: "mergeMempools peer timeout", + require_reply: false, + extra: peer.identity, + }) + }, ms) }) return Promise.race([ @@ -36,6 +36,7 @@ function withTimeout( } export async function mergeMempools(mempool: Transaction[], shard: Peer[]) { + const now = Date.now() // INFO: if shard only contains us, skip network requests shard = shard.filter(peer => peer.identity !== getSharedState.publicKeyHex) if (shard.length === 0) { @@ -48,7 +49,7 @@ export async function mergeMempools(mempool: Transaction[], shard: Peer[]) { } const promises = shard.map(peer => { - log.debug( + log.only( `[mergeMempools] Merging mempool with ${peer.connection.string}`, ) return withTimeout( @@ -85,7 +86,7 @@ export async function mergeMempools(mempool: Transaction[], shard: Peer[]) { } const txs = response.response as Transaction[] - log.debug( + log.only( `[mergeMempools] Received ${txs.length} transactions from ${peer.connection.string}`, ) for (const tx of txs) { @@ -99,8 +100,12 @@ export async function mergeMempools(mempool: Transaction[], shard: Peer[]) { return } - log.debug( + log.only( `[mergeMempools] Forwarding ${merged.size} unique txs to Mempool.receive`, ) await Mempool.receive(Array.from(merged.values())) + const end = Date.now() + log.only( + `[mergeMempools] Time taken: ${(end - now) / 1000}s with ${shard.length} peers`, + ) } diff --git a/src/libs/consensus/v2/types/secretaryManager.ts b/src/libs/consensus/v2/types/secretaryManager.ts index 44e1f7211..ceeb56427 100644 --- a/src/libs/consensus/v2/types/secretaryManager.ts +++ b/src/libs/consensus/v2/types/secretaryManager.ts @@ -76,22 +76,22 @@ export default class SecretaryManager { // Assigning the secretary and its key this.shard.secretaryKey = this.secretary.identity - log.debug("\n\n\n") - log.debug("INITIALIZED SHARD:") - log.debug( + log.only("\n\n\n") + log.only("INITIALIZED SHARD:") + log.only( `SHARD: ${JSON.stringify( this.shard.members.map(m => m.connection.string), )}`, ) - log.debug(`SECRETARY: ${this.secretary.identity}`) + log.only(`SECRETARY: ${this.secretary.identity}`) // INFO: Start the secretary routine if (this.checkIfWeAreSecretary()) { - log.debug( + log.only( "⬜️ We are the secretary ⬜️. starting the secretary routine", ) this.secretaryRoutine().finally(async () => { - log.debug("Secretary routine finished confetti confetti 🎊🎉") + log.only("Secretary routine finished confetti confetti 🎊🎉") }) } @@ -489,7 +489,7 @@ export default class SecretaryManager { // INFO: Check if that peer was the one holding the green light if (shouldRelease) { // INFO: If we are in the last phase, stop the secretary routine - if (this.ourValidatorPhase.currentPhase === 7) { + if (this.ourValidatorPhase.currentPhase === 6) { this.runSecretaryRoutine = false } @@ -875,42 +875,47 @@ export default class SecretaryManager { public async endConsensusRoutine() { log.debug("Ending the consensus routine") - const manager = SecretaryManager.instances.get(this.shard.blockRef) + let manager: SecretaryManager = null + + if (this.shard) { + manager = SecretaryManager.instances.get(this.shard.blockRef) + } if (manager) { manager.runSecretaryRoutine = false - } - const filter = (key: string) => - key.includes("greenLight" + this.shard.blockRef) + const filter = (key: string) => + key.includes("greenLight" + this.shard.blockRef) - const waiterKeys = Array.from(Waiter.waitList.keys()).filter(filter) - const waiters = waiterKeys.map(key => Waiter.wait(key)) + const waiterKeys = Array.from(Waiter.waitList.keys()).filter(filter) + const waiters = waiterKeys.map(key => Waiter.wait(key)) - log.debug( - "💁💁💁💁💁💁💁💁 WAITING FOR HANGING GREENLIGHTS 💁💁💁💁💁💁💁💁💁💁", - ) - log.debug(`Waiter keys: ${JSON.stringify(waiterKeys)}`) - try { - await Promise.all(waiters) - } catch (error) { - log.error( - `[SECRETARY] Error waiting for hanging greenlights: ${error}`, + log.debug( + "💁💁💁💁💁💁💁💁 WAITING FOR HANGING GREENLIGHTS 💁💁💁💁💁💁💁💁💁💁", ) - process.exit(1) + log.debug(`Waiter keys: ${JSON.stringify(waiterKeys)}`) + try { + await Promise.all(waiters) + } catch (error) { + log.error( + `[SECRETARY] Error waiting for hanging greenlights: ${error}`, + ) + process.exit(1) + } + + Waiter.preHeld + .keys() + .filter(filter) + .forEach(key => Waiter.preHeld.delete(key)) } // INFO: Delete pre-held keys for ended consensus round - Waiter.preHeld - .keys() - .filter(filter) - .forEach(key => Waiter.preHeld.delete(key)) log.debug("HANGING GREENLIGHTS RESOLVED") log.debug("[SECRETARY ROUTINE] Secretary routine finished 🎉") Waiter.abort(Waiter.keys.SET_WAIT_STATUS) - if (SecretaryManager.getInstance(this.shard.blockRef) === this) { + if (manager && manager === this) { log.debug("deleting the instance") SecretaryManager.instances.delete(this.shard.blockRef) } diff --git a/src/libs/crypto/hashing.ts b/src/libs/crypto/hashing.ts index 3bd4bba60..e7d2615d4 100644 --- a/src/libs/crypto/hashing.ts +++ b/src/libs/crypto/hashing.ts @@ -9,14 +9,11 @@ KyneSys Labs: https://www.kynesys.xyz/ */ -import forge from "node-forge" import crypto from "crypto" export default class Hashing { static sha256(message: string) { - const md = forge.sha256.create() - md.update(message) - return md.digest().toHex() + return crypto.createHash("sha256").update(message).digest("hex") } // New: hash exact bytes deterministically diff --git a/src/libs/l2ps/L2PSBatchAggregator.ts b/src/libs/l2ps/L2PSBatchAggregator.ts index 81a8f2aa4..fd70f1176 100644 --- a/src/libs/l2ps/L2PSBatchAggregator.ts +++ b/src/libs/l2ps/L2PSBatchAggregator.ts @@ -13,6 +13,7 @@ import type { GCREdit } from "@kynesyslabs/demosdk/types" import { Config } from "src/config" import type { L2PSBatchPayload } from "./types" import { ZK_CIRCUIT_MAX_BATCH_SIZE, BATCH_SIGNATURE_DOMAIN } from "./constants" +import TxValidatorPool from "../blockchain/validation/txValidatorPool" // Re-export for backward compatibility export type { L2PSBatchPayload } from "./types" @@ -349,6 +350,7 @@ export class L2PSBatchAggregator { const updated = await L2PSMempool.updateStatusBatch(hashes, L2PS_STATUS.BATCHED) // Update transaction statuses in l2ps_transactions table (history) + // eslint-disable-next-line @typescript-eslint/naming-convention const L2PSTransactionExecutor = (await import("./L2PSTransactionExecutor")).default for (const txHash of hashes) { try { @@ -707,7 +709,7 @@ export class L2PSBatchAggregator { // Sign with domain separation to prevent cross-protocol signature reuse // Domain prefix ensures this signature cannot be replayed in other contexts const domainSeparatedMessage = `${this.SIGNATURE_DOMAIN}:${contentString}` - const signature = await ucrypto.sign( + const signature = await TxValidatorPool.getInstance().sign( sharedState.signingAlgorithm, new TextEncoder().encode(domainSeparatedMessage), ) diff --git a/src/libs/l2ps/parallelNetworks.ts b/src/libs/l2ps/parallelNetworks.ts index dbf3a5a83..3f1c32b3f 100644 --- a/src/libs/l2ps/parallelNetworks.ts +++ b/src/libs/l2ps/parallelNetworks.ts @@ -12,6 +12,7 @@ import type { L2PSTransaction } from "@kynesyslabs/demosdk/types" import { getSharedState } from "@/utilities/sharedState" import log from "@/utilities/logger" import { getErrorMessage } from "@/utilities/errorMessage" +import TxValidatorPool from "../blockchain/validation/txValidatorPool" /** * Configuration interface for an L2PS node. @@ -298,7 +299,7 @@ export default class ParallelNetworks { // Sign encrypted transaction with node's private key const sharedState = getSharedState - const signature = await ucrypto.sign( + const signature = await TxValidatorPool.getInstance().sign( sharedState.signingAlgorithm, new TextEncoder().encode(JSON.stringify(encryptedTx.content)), ) @@ -327,7 +328,7 @@ export default class ParallelNetworks { // Verify signature before decrypting if (encryptedTx.signature) { - const isValid = await ucrypto.verify({ + const isValid = await TxValidatorPool.getInstance().verify({ algorithm: encryptedTx.signature.type as SigningAlgorithm, message: new TextEncoder().encode(JSON.stringify(encryptedTx.content)), publicKey: hexToUint8Array(encryptedTx.content.from as string), diff --git a/src/libs/network/dtr/dtrmanager.ts b/src/libs/network/dtr/dtrmanager.ts index 633064881..7de505255 100644 --- a/src/libs/network/dtr/dtrmanager.ts +++ b/src/libs/network/dtr/dtrmanager.ts @@ -21,6 +21,7 @@ import TxUtils from "../../blockchain/transaction" import { Waiter } from "@/utilities/waiter" import Block from "@/libs/blockchain/block" import Chain from "@/libs/blockchain/chain" +import TxValidatorPool from "@/libs/blockchain/validation/txValidatorPool" /** * DTR (Distributed Transaction Routing) Relay Retry Service @@ -161,9 +162,7 @@ export class DTRManager { const availableValidators = await this.getValidatorsOptimized() if (availableValidators.length === 0) { - log.warn( - "[DTR RetryService] No validators available for relay", - ) + log.warn("[DTR RetryService] No validators available for relay") return } @@ -246,7 +245,10 @@ export class DTRManager { params: [ { message: "RELAY_TX", - data: payload, + data: { + payload, + blockNumber: getSharedState.lastBlockNumber, + }, }, ], } @@ -264,10 +266,7 @@ export class DTRManager { }, } } catch (error) { - log.error( - "[DTR] Error relaying transaction to validator: " + - error, - ) + log.error("[DTR] Error relaying transaction to validator: " + error) return { result: 500, response: { @@ -351,7 +350,8 @@ export class DTRManager { `[DTR RetryService] Validator ${validator.identity.substring(0, 8)}... rejected ${txHash}: ${result.response}`, ) } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) + const errorMsg = + error instanceof Error ? error.message : String(error) log.debug( `[DTR RetryService] Validator ${validator.identity.substring(0, 8)}... error for ${txHash}: ${errorMsg}`, ) @@ -366,11 +366,14 @@ export class DTRManager { ) } - static async receiveRelayedTransactions( - payloads: ValidityData[], - ): Promise { + static async receiveRelayedTransactions(data: { + payload: ValidityData[] + blockNumber: number + }): Promise { const response = await Promise.all( - payloads.map(payload => this.receiveRelayedTransaction(payload)), + data.payload.map(payload => + this.receiveRelayedTransaction(payload, data.blockNumber), + ), ) return { @@ -426,7 +429,10 @@ export class DTRManager { * * @returns RPCResponse */ - static async receiveRelayedTransaction(validityData: ValidityData) { + static async receiveRelayedTransaction( + validityData: ValidityData, + blockNumber: number, + ) { const response: RPCResponse = { result: 200, response: null, @@ -507,14 +513,19 @@ export class DTRManager { } // 3. Verify validity data against sender signature - const isSignatureValid = await ucrypto.verify({ - algorithm: validityData.rpc_public_key.type as SigningAlgorithm, - message: new TextEncoder().encode( - Hashing.sha256(JSON.stringify(validityData.data)), - ), - publicKey: hexToUint8Array(validityData.rpc_public_key.data), - signature: hexToUint8Array(validityData.signature.data), - }) + const isSignatureValid = await TxValidatorPool.getInstance().verify( + { + algorithm: validityData.rpc_public_key + .type as SigningAlgorithm, + message: new TextEncoder().encode( + Hashing.sha256(JSON.stringify(validityData.data)), + ), + publicKey: hexToUint8Array( + validityData.rpc_public_key.data, + ), + signature: hexToUint8Array(validityData.signature.data), + }, + ) log.debug("[DTR] Relayed tx isSignatureValid: " + isSignatureValid) log.debug( @@ -590,9 +601,7 @@ export class DTRManager { ...tx, reference_block: validityData.data.reference_block, }, - - // INFO: Enforce block ref - getSharedState.lastBlockNumber + 1, + blockNumber, ) log.debug( @@ -646,7 +655,10 @@ export class DTRManager { // eslint-disable-next-line no-constant-condition while (true) { try { - cvsa = await Waiter.wait(Waiter.keys.DTR_WAIT_FOR_BLOCK, 30_000) + cvsa = await Waiter.wait( + Waiter.keys.DTR_WAIT_FOR_BLOCK, + 120_000, + ) log.debug("waitForBlockThenRelay resolved. CVSA: " + cvsa) break } catch (error) { @@ -679,10 +691,13 @@ export class DTRManager { ) return await Promise.all( txsToRelay.map(tx => { - Mempool.addTransaction({ - ...tx.data.transaction, - reference_block: tx.data.reference_block, - }) + Mempool.addTransaction( + { + ...tx.data.transaction, + reference_block: tx.data.reference_block, + }, + getSharedState.lastBlockNumber + 1, + ) // INFO: Remove tx from cache DTRManager.validityDataCache.delete( diff --git a/src/libs/network/endpointExecution.ts b/src/libs/network/endpointExecution.ts index e2afd4604..a07df5011 100644 --- a/src/libs/network/endpointExecution.ts +++ b/src/libs/network/endpointExecution.ts @@ -1,6 +1,6 @@ import Chain from "src/libs/blockchain/chain" import Mempool from "src/libs/blockchain/mempool" -import type { Transaction, L2PSTransaction } from "@kynesyslabs/demosdk/types" +import type { L2PSTransaction } from "@kynesyslabs/demosdk/types" import Hashing from "src/libs/crypto/hashing" import { getSharedState } from "src/utilities/sharedState" import _ from "lodash" @@ -8,12 +8,10 @@ import { ExecutionResult, ValidityData, XMScript, - RPCResponse, IWeb2Payload, SigningAlgorithm, } from "@kynesyslabs/demosdk/types" import log from "src/utilities/logger" -import isValidatorForNextBlock from "src/libs/consensus/v2/routines/isValidator" import handleDemosWorkRequest from "./routines/transactions/demosWork/handleDemosWorkRequest" import multichainDispatcher from "src/features/multichain/XMDispatcher" import { DemoScript } from "@kynesyslabs/demosdk/types" @@ -30,6 +28,7 @@ import { NativeBridgeOperationCompiled } from "@kynesyslabs/demosdk/bridge" import handleNativeBridgeTx from "./routines/transactions/handleNativeBridgeTx" import { DTRManager } from "./dtr/dtrmanager" import handleL2PS from "./routines/transactions/handleL2PS" +import TxValidatorPool from "../blockchain/validation/txValidatorPool" function isReferenceBlockAllowed(referenceBlock: number, lastBlock: number) { return ( @@ -89,14 +88,20 @@ export async function handleExecuteTransaction( return result } + const handleVerifyStart = Date.now() const hashedData = Hashing.sha256(JSON.stringify(validatedData.data)) - const signatureValid = await ucrypto.verify({ + const signatureValid = await TxValidatorPool.getInstance().verify({ algorithm: validatedData.signature.type as SigningAlgorithm, message: new TextEncoder().encode(hashedData), publicKey: hexToUint8Array(validatedData.rpc_public_key.data) as any, signature: hexToUint8Array(validatedData.signature.data) as any, }) + const handleVerifyEnd = Date.now() + log.only( + `[SERVER] Transaction verified in ${handleVerifyEnd - handleVerifyStart}ms`, + ) + if (!signatureValid) { log.error( "[handleExecuteTransaction] Invalid validityData signature: " + @@ -111,7 +116,12 @@ export async function handleExecuteTransaction( } const blockNumber = validatedData.data.reference_block + const handleBlockNumberStart = Date.now() const lastBlockNumber = await Chain.getLastBlockNumber() + const handleBlockNumberEnd = Date.now() + log.only( + `[SERVER] Read last block number in ${handleBlockNumberEnd - handleBlockNumberStart}ms`, + ) if (!isReferenceBlockAllowed(blockNumber, lastBlockNumber)) { log.error( @@ -138,7 +148,12 @@ export async function handleExecuteTransaction( } log.info("SERVER", fname + "Valid validityData!") + const cloneTxStart = Date.now() const tx = _.cloneDeep(validatedData.data.transaction) + const cloneTxEnd = Date.now() + log.only( + `[SERVER] Cloned tx in ${cloneTxEnd - cloneTxStart}ms`, + ) let payload: DemoScript | any switch (tx.content.type) { @@ -219,10 +234,15 @@ export async function handleExecuteTransaction( } case "web2Request": { + const handleWeb2RequestStart = Date.now() payload = tx.content.data[1] as IWeb2Payload const params = parseWeb2ProxyRequest(payload) const web2Result = await handleWeb2ProxyRequest(params) result.response = web2Result + const handleWeb2RequestEnd = Date.now() + log.only( + `[SERVER] Web2 request processed in ${handleWeb2RequestEnd - handleWeb2RequestStart}ms`, + ) break } @@ -335,54 +355,54 @@ export async function handleExecuteTransaction( } log.debug("PROD: " + getSharedState.PROD) - const { isValidator, validators } = await isValidatorForNextBlock() - - if (!isValidator) { - log.debug( - "[DTR] Non-validator node: attempting relay to all validators", - ) - const availableValidators = validators.sort( - () => Math.random() - 0.5, - ) - - log.debug( - `[DTR] Found ${availableValidators.length} available validators, trying all`, - ) - - const results = await Promise.allSettled( - availableValidators.map(validator => - DTRManager.relayTransactions(validator, [validatedData]), - ), - ) - - for (const result of results) { - if (result.status === "fulfilled") { - const response = result.value - if (response.result === 200) { - continue - } - DTRManager.validityDataCache.set( - validatedData.data.transaction.hash, - validatedData, - ) - } - } - - return { - success: true, - response: { - message: "Transaction relayed to validators", - }, - extra: { - confirmationBlock: getSharedState.lastBlockNumber + 1, - }, - require_reply: false, - } - } - - if (getSharedState.inConsensusLoop) { - return await DTRManager.inConsensusHandler(validatedData) - } + // const { isValidator, validators } = await isValidatorForNextBlock() + + // if (!isValidator) { + // log.debug( + // "[DTR] Non-validator node: attempting relay to all validators", + // ) + // const availableValidators = validators.sort( + // () => Math.random() - 0.5, + // ) + + // log.debug( + // `[DTR] Found ${availableValidators.length} available validators, trying all`, + // ) + + // const results = await Promise.allSettled( + // availableValidators.map(validator => + // DTRManager.relayTransactions(validator, [validatedData]), + // ), + // ) + + // for (const result of results) { + // if (result.status === "fulfilled") { + // const response = result.value + // if (response.result === 200) { + // continue + // } + // DTRManager.validityDataCache.set( + // validatedData.data.transaction.hash, + // validatedData, + // ) + // } + // } + + // return { + // success: true, + // response: { + // message: "Transaction relayed to validators", + // }, + // extra: { + // confirmationBlock: getSharedState.lastBlockNumber + 1, + // }, + // require_reply: false, + // } + // } + + // if (getSharedState.inConsensusLoop) { + // return await DTRManager.inConsensusHandler(validatedData) + // } log.debug("--------------------------------") log.debug("In consensus loop: " + getSharedState.inConsensusLoop) diff --git a/src/libs/network/endpointValidation.ts b/src/libs/network/endpointValidation.ts index aa7234ed9..4752124bf 100644 --- a/src/libs/network/endpointValidation.ts +++ b/src/libs/network/endpointValidation.ts @@ -1,7 +1,11 @@ import Chain from "src/libs/blockchain/chain" import { confirmTransaction } from "src/libs/blockchain/routines/validateTransaction" import type { Transaction } from "@kynesyslabs/demosdk/types" -import { ValidityData, GCREdit, SigningAlgorithm } from "@kynesyslabs/demosdk/types" +import { + ValidityData, + GCREdit, + SigningAlgorithm, +} from "@kynesyslabs/demosdk/types" import Hashing from "src/libs/crypto/hashing" import { getSharedState } from "src/utilities/sharedState" import log from "src/utilities/logger" @@ -13,6 +17,8 @@ import { ucrypto, uint8ArrayToHex, } from "@kynesyslabs/demosdk/encryption" +import TxValidatorPool from "../blockchain/validation/txValidatorPool" +import GCR from "../blockchain/gcr/gcr" export async function handleValidateTransaction( tx: Transaction, @@ -23,16 +29,29 @@ export async function handleValidateTransaction( log.info("SERVER", fname + "Handling transaction...") let validationData: ValidityData try { + const handleConfirmTransactionStart = Date.now() validationData = await confirmTransaction(tx, sender) + const handleConfirmTransactionEnd = Date.now() + log.only( + `[handleValidateTransaction] Transaction confirmed in ${handleConfirmTransactionEnd - handleConfirmTransactionStart}ms`, + ) + const handleGcrEditsStart = Date.now() const gcrEdits = await GCRGeneration.generate(tx) + const handleGcrEditsEnd = Date.now() + log.only( + `[handleValidateTransaction] GCR edits generated in ${handleGcrEditsEnd - handleGcrEditsStart}ms`, + ) gcrEdits.forEach((gcredit: GCREdit) => { gcredit.txhash = "" }) + const handleGcrEditsHashStart = Date.now() const gcrEditsHash = Hashing.sha256(JSON.stringify(gcrEdits)) - log.debug( - "[handleValidateTransaction] gcrEditsHash: " + gcrEditsHash, + const handleGcrEditsHashEnd = Date.now() + log.only( + `[handleValidateTransaction] GCR edits hash generated in ${handleGcrEditsHashEnd - handleGcrEditsHashStart}ms`, ) + log.debug("[handleValidateTransaction] gcrEditsHash: " + gcrEditsHash) const txGcrEditsHash = Hashing.sha256( JSON.stringify(tx.content.gcr_edits), ) @@ -43,9 +62,9 @@ export async function handleValidateTransaction( if (!comparison) { log.error( "[handleValidateTransaction] GCREdit mismatch: " + - txGcrEditsHash + - " <> " + - gcrEditsHash, + txGcrEditsHash + + " <> " + + gcrEditsHash, ) } if (comparison) { @@ -64,21 +83,18 @@ export async function handleValidateTransaction( (edit.account as any)?.toString() === sender)), ) .reduce( - (sum: bigint, edit: GCREdit) => sum + BigInt((edit as any).amount), + (sum: bigint, edit: GCREdit) => + sum + BigInt((edit as any).amount), 0n, ) if (totalFee > 0n) { - const db = await Datasource.getInstance() - const gcrMainRepo = db - .getDataSource() - .getRepository(GCRMain) - const account = await gcrMainRepo.findOneBy({ - pubkey: sender, - }) - const senderBalance = account - ? BigInt(account.balance) - : 0n + const checkFeeStart = Date.now() + const senderBalance = await GCR.getAccountBalance(sender) + const checkFeeEnd = Date.now() + log.only( + `[handleValidateTransaction] Check fee in ${checkFeeEnd - checkFeeStart}ms`, + ) if (senderBalance < totalFee) { throw new Error( `Insufficient balance: required ${totalFee.toString()}, available ${senderBalance.toString()}`, @@ -104,7 +120,7 @@ export async function handleValidateTransaction( const hashedValidationData = Hashing.sha256( JSON.stringify(validationData.data), ) - const signature = await ucrypto.sign( + const signature = await TxValidatorPool.getInstance().sign( getSharedState.signingAlgorithm, new TextEncoder().encode(hashedValidationData), ) diff --git a/src/libs/network/handlers/identityHandlers.ts b/src/libs/network/handlers/identityHandlers.ts index 78e41bb2d..d9d452cb8 100644 --- a/src/libs/network/handlers/identityHandlers.ts +++ b/src/libs/network/handlers/identityHandlers.ts @@ -2,11 +2,11 @@ import { Twitter } from "../../identity/tools/twitter" import { Discord } from "../../identity/tools/discord" import { UDIdentityManager } from "../../blockchain/gcr/gcr_routines/udIdentityManager" import ensureGCRForUser from "../../blockchain/gcr/gcr_routines/ensureGCRForUser" -import HandleGCR from "../../blockchain/gcr/handleGCR" import type { Tweet } from "@kynesyslabs/demosdk/types" import type { DiscordMessage } from "../../identity/tools/discord" import log from "src/utilities/logger" import type { NodeCallHandler } from "./types" +import GCR from "@/libs/blockchain/gcr/gcr" export const identityHandlers: Record = { getAddressInfo: async (data, response) => { @@ -37,22 +37,8 @@ export const identityHandlers: Record = { return response }, - getNativeStatus: async (data, _response) => { - return await HandleGCR.getNativeStatus( - data.address, - ...(data.options ? [data.options] : []), - ) - }, - - getNativeProperties: async (data, _response) => { - return await HandleGCR.getNativeProperties( - data.address, - ...(data.options ? [data.options] : []), - ) - }, - getNativeSubnetsTxs: async (data, _response) => { - return await HandleGCR.getNativeSubnetsTxs( + return await GCR.getNativeSubnetsTxs( data.subnetId, ...(data.options ? [data.options] : []), ) diff --git a/src/libs/network/handlers/miscHandlers.ts b/src/libs/network/handlers/miscHandlers.ts index 2c978231b..e29e8f871 100644 --- a/src/libs/network/handlers/miscHandlers.ts +++ b/src/libs/network/handlers/miscHandlers.ts @@ -5,9 +5,10 @@ import log from "src/utilities/logger" import type { NodeCallHandler } from "./types" export const miscHandlers: Record = { + // eslint-disable-next-line @typescript-eslint/naming-convention RELAY_TX: async (data, _response) => { return await DTRManager.receiveRelayedTransactions( - data as ValidityData[], + data as {payload: ValidityData[], blockNumber: number}, ) }, diff --git a/src/libs/network/handlers/tlsnotaryHandlers.ts b/src/libs/network/handlers/tlsnotaryHandlers.ts index 63537a633..71a246cb6 100644 --- a/src/libs/network/handlers/tlsnotaryHandlers.ts +++ b/src/libs/network/handlers/tlsnotaryHandlers.ts @@ -222,4 +222,4 @@ export const tlsnotaryHandlers: Record = { } return response }, -} +} \ No newline at end of file diff --git a/src/libs/network/manageExecution.ts b/src/libs/network/manageExecution.ts index 5b410a061..bcefc2c7d 100644 --- a/src/libs/network/manageExecution.ts +++ b/src/libs/network/manageExecution.ts @@ -40,10 +40,15 @@ export async function manageExecution( // Then we send the validation data to the client that can use it to execute the tx case "confirmTx": { log.info("SERVER", "Received confirmTx") + const handleValidateStart = Date.now() const validityData = await ServerHandlers.handleValidateTransaction( content.data as Transaction, sender, ) + const handleValidateEnd = Date.now() + log.only( + `[confirmTx] Transaction validated in ${handleValidateEnd - handleValidateStart}ms`, + ) returnValue.result = 200 returnValue.response = validityData returnValue.require_reply = false @@ -73,10 +78,15 @@ export async function manageExecution( } try { + const handleExecuteStart = Date.now() const result = await ServerHandlers.handleExecuteTransaction( validityDataPayload, sender, ) + const handleExecuteEnd = Date.now() + log.only( + `[broadcastTx] Transaction executed in ${handleExecuteEnd - handleExecuteStart}ms`, + ) log.debug( "[SERVER] Transaction executed. Sending back the result", ) diff --git a/src/libs/network/manageHelloPeer.ts b/src/libs/network/manageHelloPeer.ts index 8c389a627..d2feb988f 100644 --- a/src/libs/network/manageHelloPeer.ts +++ b/src/libs/network/manageHelloPeer.ts @@ -7,6 +7,7 @@ import { emptyResponse } from "./server_rpc" import { getSharedState } from "src/utilities/sharedState" import { hexToUint8Array, ucrypto } from "@kynesyslabs/demosdk/encryption" import { RPCResponse, SigningAlgorithm } from "@kynesyslabs/demosdk/types" +import TxValidatorPool from "../blockchain/validation/txValidatorPool" export interface HelloPeerRequest { url: string @@ -50,16 +51,14 @@ export async function manageHelloPeer( // Check if the authentication info is valid based on the sender info from the headers log.info("[Hello Peer Listener] Verifying authentication info...") - const signatureValid = await ucrypto.verify({ + const signatureValid = await TxValidatorPool.getInstance().verify({ algorithm: content.signature.type, message: new TextEncoder().encode(content.url), signature: hexToUint8Array(content.signature.data as string), - publicKey: hexToUint8Array(content.publicKey), + publicKey: hexToUint8Array(sender), }) - const isValid = sender === content.publicKey && signatureValid - - if (!isValid) { + if (!signatureValid) { log.error( "[Hello Peer Listener] Invalid authentication info for: " + peerObject.identity + diff --git a/src/libs/network/routines/nodecalls/getTxsByHashes.ts b/src/libs/network/routines/nodecalls/getTxsByHashes.ts index 0af84fcdd..8e53c4e22 100644 --- a/src/libs/network/routines/nodecalls/getTxsByHashes.ts +++ b/src/libs/network/routines/nodecalls/getTxsByHashes.ts @@ -1,3 +1,4 @@ +import Mempool from "@/libs/blockchain/mempool" import { getSharedState } from "@/utilities/sharedState" import { RPCResponse } from "@kynesyslabs/demosdk/types" import { handleError } from "src/errors" @@ -41,7 +42,31 @@ export default async function getTxsByHashes( hashes = data.hashes.slice(0, getSharedState.batchSyncTxLimit) } + const toGet = new Set(hashes) const transactions = await Chain.getTransactionsFromHashes(hashes) + + for (const tx of transactions) { + toGet.delete(tx.hash) + } + + if (toGet.size > 0) { + // NOTE: If peer tries to fetch transactions for a block not + // finalized by this peer yet, + // (because block is broadcasted right after voting, + // and it's possible that this peer might be slow) + // the block txs will not be in the transactions table yet, + // fetch them from the mempool, and update the blockNumber to match block + const missing = await Mempool.getTransactionsByHashes( + Array.from(toGet), + ) + transactions.push( + ...missing.map(tx => ({ + ...tx, + blockNumber: getSharedState.lastBlockNumber, + })), + ) + } + if (transactions && transactions.length > 0) { return { result: 200, diff --git a/src/libs/network/server_rpc.ts b/src/libs/network/server_rpc.ts index abdaf940d..51f1576ab 100644 --- a/src/libs/network/server_rpc.ts +++ b/src/libs/network/server_rpc.ts @@ -28,6 +28,29 @@ export async function serverRpcBun() { const rateLimiter = RateLimiter.getInstance() // Apply middlewares + // server.use(async (req, next) => { + // const url = new URL(req.url) + // let bodyLog = "" + // if (req.method === "POST") { + // try { + // const cloned = req.clone() + // const body = await cloned.text() + // bodyLog = body.length > 1024 + // ? body.slice(0, 768) + "...(truncated)..." + body.slice(-256) + // : body + // } catch { + // bodyLog = "(unreadable body)" + // } + // } + // const requestStart = performance.now() + // const response = await next() + // const durationMs = (performance.now() - requestStart).toFixed(2) + // const message = bodyLog + // ? `[HTTP] ${req.method} ${url.pathname} -> ${response.status} (${durationMs}ms) body: ${bodyLog}` + // : `[HTTP] ${req.method} ${url.pathname} -> ${response.status} (${durationMs}ms)` + // log.only(message, false) + // return response + // }) server.use(cors()) server.use(rateLimiter.createMiddleware()) server.use(json()) diff --git a/src/libs/network/verifySignature.ts b/src/libs/network/verifySignature.ts index 07a80afd9..0679c1b1c 100644 --- a/src/libs/network/verifySignature.ts +++ b/src/libs/network/verifySignature.ts @@ -8,6 +8,7 @@ import { ucrypto, hexToUint8Array } from "@kynesyslabs/demosdk/encryption" import { Ed25519SignedObject, signedObject } from "@kynesyslabs/demosdk/types" import log from "src/utilities/logger" +import TxValidatorPool from "../blockchain/validation/txValidatorPool" export interface VerificationResult { /** @@ -102,7 +103,7 @@ export async function verifySignature( } as Ed25519SignedObject } - const isValid = await ucrypto.verify(signatureObj) + const isValid = await TxValidatorPool.getInstance().verify(signatureObj) if (isValid) { log.debug(`[verifySignature] Valid signature for: ${identity}`) diff --git a/src/libs/omniprotocol/integration/peerAdapter.ts b/src/libs/omniprotocol/integration/peerAdapter.ts index f0956f24f..831861370 100644 --- a/src/libs/omniprotocol/integration/peerAdapter.ts +++ b/src/libs/omniprotocol/integration/peerAdapter.ts @@ -17,6 +17,7 @@ import { } from "../serialization/control" import { OmniOpcode } from "../protocol/opcodes" import { getSharedState } from "@/utilities/sharedState" +import { uint8ArrayToHex } from "@kynesyslabs/demosdk/encryption" export type AdapterOptions = BaseAdapterOptions @@ -108,7 +109,9 @@ export class PeerOmniAdapter extends BaseOmniAdapter { extra: decoded.extra, } } catch (error) { - handleError(error, "NETWORK", { source: "OmniProtocol PeerAdapter.adaptCall" }) + handleError(error, "NETWORK", { + source: "OmniProtocol PeerAdapter.adaptCall", + }) // Check for fatal mode - will exit if OMNI_FATAL=true this.handleFatalError( error, diff --git a/src/libs/peer/Peer.ts b/src/libs/peer/Peer.ts index a3a0fed6e..660843254 100644 --- a/src/libs/peer/Peer.ts +++ b/src/libs/peer/Peer.ts @@ -7,6 +7,7 @@ import { NodeCall } from "../network/manageNodeCall" import { ucrypto, uint8ArrayToHex } from "@kynesyslabs/demosdk/encryption" import PeerManager from "./PeerManager" import { sleep } from "@kynesyslabs/demosdk/utils" +import TxValidatorPool from "../blockchain/validation/txValidatorPool" export interface SyncData { status: boolean @@ -206,7 +207,7 @@ export default class Peer { await ucrypto.getIdentity(getSharedState.signingAlgorithm) ).publicKey const hexPublicKey = uint8ArrayToHex(ourPublicKey as Uint8Array) - const bufferSignature = await ucrypto.sign( + const bufferSignature = await TxValidatorPool.getInstance().sign( getSharedState.signingAlgorithm, new TextEncoder().encode(hexPublicKey), ) @@ -285,7 +286,7 @@ export default class Peer { await ucrypto.getIdentity(getSharedState.signingAlgorithm) ).publicKey const hexPublicKey = uint8ArrayToHex(ourPublicKey as Uint8Array) - const bufferSignature = await ucrypto.sign( + const bufferSignature = await TxValidatorPool.getInstance().sign( getSharedState.signingAlgorithm, new TextEncoder().encode(hexPublicKey), ) diff --git a/src/libs/peer/PeerManager.ts b/src/libs/peer/PeerManager.ts index 339d7b461..1cd748e88 100644 --- a/src/libs/peer/PeerManager.ts +++ b/src/libs/peer/PeerManager.ts @@ -16,6 +16,7 @@ import { getSharedState } from "src/utilities/sharedState" import { RPCResponse } from "@kynesyslabs/demosdk/types" import { HelloPeerRequest } from "../network/manageHelloPeer" import { ucrypto, uint8ArrayToHex } from "@kynesyslabs/demosdk/encryption" +import TxValidatorPool from "../blockchain/validation/txValidatorPool" export default class PeerManager { private static instance: PeerManager @@ -243,7 +244,10 @@ export default class PeerManager { peer.connection.string, ) log.error("[PEERMANAGER] Peer not added: " + peer.identity) - return [false, "Invalid connection string: " + peer.connection.string] + return [ + false, + "Invalid connection string: " + peer.connection.string, + ] } // REVIEW check for duplicates @@ -384,17 +388,11 @@ export default class PeerManager { // TODO test and finalize this method log.debug("[Hello Peer] Saying hello to peer " + peer.identity) const connectionString = getSharedState.exposedUrl // ? Are we sure about this - const signedConnectionString = await ucrypto.sign( + const signedConnectionString = await TxValidatorPool.getInstance().sign( getSharedState.signingAlgorithm, new TextEncoder().encode(connectionString), ) - log.debug("[Hello Peer] Signing connection string: " + connectionString) - log.debug( - "[Hello Peer] Signed connection string: " + - uint8ArrayToHex(signedConnectionString.signature), - ) - // Sending the transmission to the peer const helloRequest: HelloPeerRequest = { url: connectionString, diff --git a/src/libs/peer/routines/getPeerIdentity.ts b/src/libs/peer/routines/getPeerIdentity.ts index c188c9d31..2a53f7dd3 100644 --- a/src/libs/peer/routines/getPeerIdentity.ts +++ b/src/libs/peer/routines/getPeerIdentity.ts @@ -15,6 +15,7 @@ import crypto from "node:crypto" import Peer from "../Peer" import { getSharedState } from "src/utilities/sharedState" import log from "src/utilities/logger" +import TxValidatorPool from "@/libs/blockchain/validation/txValidatorPool" type BufferPayload = { type: "Buffer" @@ -159,7 +160,7 @@ async function verifyChallenge( : signature // Perform proper ed25519 signature verification - const isValid = await ucrypto.verify({ + const isValid = await TxValidatorPool.getInstance().verify({ algorithm: "ed25519", message: new TextEncoder().encode(expectedMessage), publicKey: hexToUint8Array(normalizedPubKey), diff --git a/src/libs/utils/demostdlib/deriveMempoolOperation.ts b/src/libs/utils/demostdlib/deriveMempoolOperation.ts index 244e9374a..949931d28 100644 --- a/src/libs/utils/demostdlib/deriveMempoolOperation.ts +++ b/src/libs/utils/demostdlib/deriveMempoolOperation.ts @@ -7,6 +7,7 @@ import { Operation } from "@kynesyslabs/demosdk/types" /* eslint-disable no-unused-vars */ import Transaction from "../../blockchain/transaction" import { ucrypto } from "@kynesyslabs/demosdk/encryption" +import TxValidatorPool from "@/libs/blockchain/validation/txValidatorPool" import { serializeTransactionContent } from "@/forks" export interface DerivableNative { @@ -233,7 +234,7 @@ export async function createTransaction( transaction.hash = Hashing.sha256( serializeTransactionContent(transaction.content, referenceHeight), ) - const signature = await ucrypto.sign( + const signature = await TxValidatorPool.getInstance().sign( getSharedState.signingAlgorithm, new TextEncoder().encode(transaction.hash), ) diff --git a/src/migrations/1779062400000-BaselineSchema.ts b/src/migrations/1779062400000-BaselineSchema.ts new file mode 100644 index 000000000..2116eee9d --- /dev/null +++ b/src/migrations/1779062400000-BaselineSchema.ts @@ -0,0 +1,578 @@ +/* LICENSE + +© 2026 by KyneSys Labs, licensed under CC BY-NC-ND 4.0 + +Full license text: https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode +Human readable license: https://creativecommons.org/licenses/by-nc-nd/4.0/ + +KyneSys Labs: https://www.kynesys.xyz/ + +*/ + +import { MigrationInterface, QueryRunner } from "typeorm" + +/** + * Baseline schema — fresh database initialisation. + * + * Reflects the **current** state of every TypeORM entity registered in + * `src/model/datasource.ts`. Designed to run against an empty database; + * no IF NOT EXISTS guards (we don't need them, and not having them surfaces + * schema drift loudly during local resets). + * + * Anything later than this baseline goes into its own dated migration. + */ +export class BaselineSchema1779062400000 implements MigrationInterface { + name = "BaselineSchema1779062400000" + + public async up(queryRunner: QueryRunner): Promise { + // ─── blocks ──────────────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE "blocks" ( + "id" SERIAL PRIMARY KEY, + "number" integer NOT NULL, + "hash" varchar NOT NULL, + "content" json NOT NULL, + "status" varchar NOT NULL, + "proposer" varchar NOT NULL, + "next_proposer" varchar NOT NULL, + "validation_data" json NOT NULL + ) + `) + await queryRunner.query( + "CREATE INDEX \"idx_blocks_number\" ON \"blocks\" (\"number\")", + ) + await queryRunner.query( + "CREATE INDEX \"idx_blocks_hash\" ON \"blocks\" (\"hash\")", + ) + + // ─── consensus ───────────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE "consensus" ( + "round" integer PRIMARY KEY, + "lastBlockHash" text, + "lastBlockTimestamp" text, + "lastConsensusHash" text, + "validators" text, + "reports" text + ) + `) + + // ─── mempooltx ───────────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE "mempooltx" ( + "hash" text PRIMARY KEY, + "timestamp" bigint NOT NULL, + "content" json NOT NULL, + "signature" json NOT NULL, + "ed25519_signature" varchar, + "status" text NOT NULL, + "blockNumber" integer NOT NULL, + "extra" jsonb, + "nonce" bigint DEFAULT 0, + "reference_block" integer NOT NULL + ) + `) + await queryRunner.query( + "CREATE INDEX \"idx_mempooltx_hash\" ON \"mempooltx\" (\"hash\")", + ) + await queryRunner.query( + "CREATE INDEX \"idx_mempooltx_reference_block\" ON \"mempooltx\" (\"reference_block\")", + ) + + // ─── pgp_key_server ─────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE "pgp_key_server" ( + "key" text PRIMARY KEY, + "email" text, + "address" text, + "others" text + ) + `) + + // ─── transactions ───────────────────────────────────────────── + // Fees are bigint (post-widening) with default 0. + // rpcAddress (DEM-665) is included. + await queryRunner.query(` + CREATE TABLE "transactions" ( + "id" SERIAL PRIMARY KEY, + "blockNumber" integer NOT NULL, + "signature" varchar NOT NULL, + "ed25519_signature" varchar, + "status" varchar NOT NULL, + "hash" varchar NOT NULL, + "content" json NOT NULL, + "type" varchar NOT NULL, + "from" varchar NOT NULL, + "from_ed25519_address" varchar, + "to" varchar NOT NULL, + "amount" bigint, + "nonce" bigint DEFAULT 0, + "timestamp" bigint NOT NULL, + "networkFee" bigint DEFAULT 0, + "rpcFee" bigint DEFAULT 0, + "additionalFee" bigint DEFAULT 0, + "rpcAddress" varchar, + CONSTRAINT "uq_transactions_hash" UNIQUE ("hash") + ) + `) + await queryRunner.query( + "CREATE INDEX \"idx_transactions_hash\" ON \"transactions\" (\"hash\")", + ) + await queryRunner.query( + "CREATE INDEX \"idx_transactions_blockNumber\" ON \"transactions\" (\"blockNumber\")", + ) + await queryRunner.query( + "CREATE INDEX \"idx_transactions_from_ed25519_address\" ON \"transactions\" (\"from_ed25519_address\")", + ) + await queryRunner.query( + "CREATE INDEX \"idx_transactions_to\" ON \"transactions\" (\"to\")", + ) + + // ─── validators ─────────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE "validators" ( + "address" text PRIMARY KEY, + "status" text, + "connection_url" text, + "staked_amount" text DEFAULT '0', + "first_seen" integer, + "valid_at" integer, + "unstake_requested_at" integer, + "unstake_available_at" integer + ) + `) + await queryRunner.query( + "CREATE INDEX \"idx_validators_unstake_requested_at\" ON \"validators\" (\"unstake_requested_at\")", + ) + await queryRunner.query( + "CREATE INDEX \"idx_validators_unstake_available_at\" ON \"validators\" (\"unstake_available_at\")", + ) + + // ─── gcr_hashes ─────────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE "gcr_hashes" ( + "id" SERIAL PRIMARY KEY, + "block" integer, + "hash" text NOT NULL + ) + `) + + // ─── gcr_subnets_txs ────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE "gcr_subnets_txs" ( + "tx_hash" text PRIMARY KEY, + "subnet_id" text NOT NULL, + "status" text NOT NULL, + "block_hash" text NOT NULL, + "block_number" integer NOT NULL, + "tx_data" json NOT NULL + ) + `) + + // ─── gcr_main ───────────────────────────────────────────────── + // balance is numeric(38, 0) so balance*1e9 cannot overflow during + // the DEM → OS denomination fork (myc#85, GH#3213220467). + await queryRunner.query(` + CREATE TABLE "gcr_main" ( + "pubkey" text PRIMARY KEY, + "nonce" integer NOT NULL, + "balance" numeric(38, 0) NOT NULL DEFAULT '0', + "identities" jsonb NOT NULL, + "points" jsonb NOT NULL DEFAULT '{}', + "referralInfo" jsonb NOT NULL DEFAULT '{}', + "flagged" boolean NOT NULL DEFAULT false, + "flaggedReason" text NOT NULL DEFAULT '', + "reviewed" boolean NOT NULL DEFAULT false, + "createdAt" timestamp NOT NULL DEFAULT now(), + "updatedAt" timestamp NOT NULL DEFAULT now() + ) + `) + await queryRunner.query( + "CREATE INDEX \"idx_gcr_main_pubkey\" ON \"gcr_main\" (\"pubkey\")", + ) + + // ─── gcr_assigned_txs ───────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE "gcr_assigned_txs" ( + "pubkey" text NOT NULL, + "tx_hash" text NOT NULL, + "block_number" integer NOT NULL DEFAULT 0, + "assigned_at" timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ("pubkey", "tx_hash") + ) + `) + await queryRunner.query( + "CREATE INDEX \"idx_gcr_assigned_txs_pubkey\" ON \"gcr_assigned_txs\" (\"pubkey\", \"block_number\")", + ) + + // ─── gcr_tlsnotary ──────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE "gcr_tlsnotary" ( + "tokenId" text PRIMARY KEY, + "owner" text NOT NULL, + "domain" text NOT NULL, + "proof" text NOT NULL, + "storageType" text NOT NULL, + "txhash" text NOT NULL, + "proofTimestamp" bigint NOT NULL, + "createdAt" timestamp NOT NULL DEFAULT now() + ) + `) + await queryRunner.query( + "CREATE INDEX \"idx_gcr_tlsnotary_owner\" ON \"gcr_tlsnotary\" (\"owner\")", + ) + await queryRunner.query( + "CREATE INDEX \"idx_gcr_tlsnotary_domain\" ON \"gcr_tlsnotary\" (\"domain\")", + ) + await queryRunner.query( + "CREATE INDEX \"idx_gcr_tlsnotary_txhash\" ON \"gcr_tlsnotary\" (\"txhash\")", + ) + + // ─── gcr_storageprogram ────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE "gcr_storageprogram" ( + "storageAddress" text PRIMARY KEY, + "owner" text NOT NULL, + "programName" text NOT NULL, + "encoding" text NOT NULL, + "data" jsonb, + "sizeBytes" integer NOT NULL, + "acl" jsonb NOT NULL, + "metadata" jsonb, + "storageLocation" text NOT NULL DEFAULT 'onchain', + "ipfsCid" text, + "salt" text, + "createdByTx" text NOT NULL, + "lastModifiedByTx" text NOT NULL, + "totalFeesPaid" bigint NOT NULL, + "isDeleted" boolean NOT NULL DEFAULT false, + "interactionTxs" text NOT NULL DEFAULT '', + "deletedByTx" text, + "createdAt" timestamp NOT NULL DEFAULT now(), + "updatedAt" timestamp NOT NULL DEFAULT now() + ) + `) + await queryRunner.query( + "CREATE INDEX \"idx_gcr_storageprogram_owner\" ON \"gcr_storageprogram\" (\"owner\")", + ) + await queryRunner.query( + "CREATE INDEX \"idx_gcr_storageprogram_programname\" ON \"gcr_storageprogram\" (\"programName\")", + ) + await queryRunner.query( + "CREATE INDEX \"idx_gcr_storageprogram_encoding\" ON \"gcr_storageprogram\" (\"encoding\")", + ) + await queryRunner.query( + "CREATE INDEX \"idx_gcr_storageprogram_storagelocation\" ON \"gcr_storageprogram\" (\"storageLocation\")", + ) + + // ─── identity_commitments ──────────────────────────────────── + await queryRunner.query(` + CREATE TABLE "identity_commitments" ( + "commitment_hash" text PRIMARY KEY, + "leaf_index" integer NOT NULL DEFAULT -1, + "provider" text NOT NULL, + "block_number" integer NOT NULL, + "transaction_hash" text NOT NULL, + "timestamp" bigint NOT NULL, + "created_at" timestamp NOT NULL DEFAULT now() + ) + `) + await queryRunner.query( + "CREATE INDEX \"idx_commitment_hash\" ON \"identity_commitments\" (\"commitment_hash\")", + ) + await queryRunner.query( + "CREATE INDEX \"idx_commitment_provider\" ON \"identity_commitments\" (\"provider\")", + ) + await queryRunner.query( + "CREATE INDEX \"idx_commitment_block\" ON \"identity_commitments\" (\"block_number\")", + ) + await queryRunner.query( + "CREATE INDEX \"idx_commitment_leaf\" ON \"identity_commitments\" (\"leaf_index\")", + ) + + // ─── used_nullifiers ───────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE "used_nullifiers" ( + "nullifier_hash" text PRIMARY KEY, + "block_number" integer NOT NULL, + "transaction_hash" text NOT NULL, + "timestamp" bigint NOT NULL, + "created_at" timestamp NOT NULL DEFAULT now() + ) + `) + await queryRunner.query( + "CREATE INDEX \"idx_nullifier_hash\" ON \"used_nullifiers\" (\"nullifier_hash\")", + ) + await queryRunner.query( + "CREATE INDEX \"idx_nullifier_block\" ON \"used_nullifiers\" (\"block_number\")", + ) + + // ─── merkle_tree_state ─────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE "merkle_tree_state" ( + "tree_id" text PRIMARY KEY, + "root_hash" text NOT NULL, + "block_number" integer NOT NULL, + "leaf_count" integer NOT NULL DEFAULT 0, + "tree_snapshot" jsonb NOT NULL, + "updated_at" timestamp NOT NULL DEFAULT now() + ) + `) + await queryRunner.query( + "CREATE INDEX \"idx_merkle_tree_id\" ON \"merkle_tree_state\" (\"tree_id\")", + ) + await queryRunner.query( + "CREATE INDEX \"idx_merkle_block\" ON \"merkle_tree_state\" (\"block_number\")", + ) + + // ─── offline_messages ──────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE "offline_messages" ( + "id" SERIAL PRIMARY KEY, + "recipient_public_key" text NOT NULL, + "sender_public_key" text NOT NULL, + "message_hash" text NOT NULL, + "encrypted_content" jsonb NOT NULL, + "signature" text NOT NULL, + "timestamp" bigint NOT NULL, + "status" text NOT NULL DEFAULT 'pending', + CONSTRAINT "uq_offline_messages_message_hash" UNIQUE ("message_hash") + ) + `) + await queryRunner.query( + "CREATE INDEX \"idx_offline_messages_recipient_public_key\" ON \"offline_messages\" (\"recipient_public_key\")", + ) + await queryRunner.query( + "CREATE INDEX \"idx_offline_messages_sender_public_key\" ON \"offline_messages\" (\"sender_public_key\")", + ) + + // ─── l2ps_hashes ───────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE "l2ps_hashes" ( + "l2ps_uid" text PRIMARY KEY, + "hash" text NOT NULL, + "transaction_count" integer NOT NULL, + "block_number" bigint NOT NULL DEFAULT 0, + "timestamp" bigint NOT NULL + ) + `) + + // ─── l2ps_mempool ──────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE "l2ps_mempool" ( + "hash" text PRIMARY KEY, + "l2ps_uid" text NOT NULL, + "sequence_number" bigint NOT NULL DEFAULT '0', + "original_hash" text NOT NULL, + "encrypted_tx" jsonb NOT NULL, + "status" text NOT NULL, + "timestamp" bigint NOT NULL, + "block_number" integer NOT NULL, + "gcr_edits" jsonb, + "affected_accounts_count" integer DEFAULT 0, + CONSTRAINT "UQ_L2PS_UID_SEQUENCE" UNIQUE ("l2ps_uid", "sequence_number") + ) + `) + await queryRunner.query( + "CREATE INDEX \"IDX_L2PS_UID_TIMESTAMP\" ON \"l2ps_mempool\" (\"l2ps_uid\", \"timestamp\")", + ) + await queryRunner.query( + "CREATE INDEX \"IDX_L2PS_UID_STATUS\" ON \"l2ps_mempool\" (\"l2ps_uid\", \"status\")", + ) + await queryRunner.query( + "CREATE INDEX \"IDX_L2PS_UID_BLOCK\" ON \"l2ps_mempool\" (\"l2ps_uid\", \"block_number\")", + ) + await queryRunner.query( + "CREATE INDEX \"IDX_L2PS_UID_SEQUENCE\" ON \"l2ps_mempool\" (\"l2ps_uid\", \"sequence_number\")", + ) + await queryRunner.query( + "CREATE INDEX \"idx_l2ps_mempool_original_hash\" ON \"l2ps_mempool\" (\"original_hash\")", + ) + await queryRunner.query( + "CREATE INDEX \"idx_l2ps_mempool_timestamp\" ON \"l2ps_mempool\" (\"timestamp\")", + ) + await queryRunner.query( + "CREATE INDEX \"idx_l2ps_mempool_block_number\" ON \"l2ps_mempool\" (\"block_number\")", + ) + + // ─── l2ps_transactions ─────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE "l2ps_transactions" ( + "id" SERIAL PRIMARY KEY, + "l2ps_uid" text NOT NULL, + "hash" text NOT NULL, + "encrypted_hash" text, + "l1_batch_hash" text, + "l1_block_number" integer, + "batch_index" integer NOT NULL DEFAULT 0, + "type" text NOT NULL, + "from_address" text NOT NULL, + "to_address" text NOT NULL, + "amount" bigint NOT NULL DEFAULT 0, + "nonce" bigint NOT NULL DEFAULT 0, + "timestamp" bigint NOT NULL, + "status" text NOT NULL DEFAULT 'pending', + "content" jsonb NOT NULL, + "execution_message" text, + "created_at" timestamp NOT NULL DEFAULT now(), + CONSTRAINT "uq_l2ps_transactions_hash" UNIQUE ("hash") + ) + `) + await queryRunner.query( + "CREATE INDEX \"IDX_L2PS_TX_UID\" ON \"l2ps_transactions\" (\"l2ps_uid\")", + ) + await queryRunner.query( + "CREATE INDEX \"IDX_L2PS_TX_HASH\" ON \"l2ps_transactions\" (\"hash\")", + ) + await queryRunner.query( + "CREATE INDEX \"IDX_L2PS_TX_FROM\" ON \"l2ps_transactions\" (\"from_address\")", + ) + await queryRunner.query( + "CREATE INDEX \"IDX_L2PS_TX_TO\" ON \"l2ps_transactions\" (\"to_address\")", + ) + await queryRunner.query( + "CREATE INDEX \"IDX_L2PS_TX_L1_BATCH\" ON \"l2ps_transactions\" (\"l1_batch_hash\")", + ) + await queryRunner.query( + "CREATE INDEX \"IDX_L2PS_TX_UID_FROM\" ON \"l2ps_transactions\" (\"l2ps_uid\", \"from_address\")", + ) + await queryRunner.query( + "CREATE INDEX \"IDX_L2PS_TX_UID_TO\" ON \"l2ps_transactions\" (\"l2ps_uid\", \"to_address\")", + ) + await queryRunner.query( + "CREATE INDEX \"IDX_L2PS_TX_BLOCK\" ON \"l2ps_transactions\" (\"l1_block_number\")", + ) + + // ─── l2ps_proofs ───────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE "l2ps_proofs" ( + "id" SERIAL PRIMARY KEY, + "l2ps_uid" text NOT NULL, + "l1_batch_hash" text NOT NULL, + "proof" jsonb NOT NULL, + "gcr_edits" jsonb NOT NULL, + "affected_accounts_count" integer NOT NULL DEFAULT 0, + "target_block_number" integer, + "applied_block_number" integer, + "status" text NOT NULL DEFAULT 'pending', + "transaction_count" integer NOT NULL DEFAULT 1, + "transactions_hash" text NOT NULL, + "transaction_hashes" jsonb NOT NULL DEFAULT '[]', + "error_message" text, + "created_at" timestamp NOT NULL DEFAULT now(), + "processed_at" timestamp + ) + `) + await queryRunner.query( + "CREATE INDEX \"IDX_L2PS_PROOFS_UID\" ON \"l2ps_proofs\" (\"l2ps_uid\")", + ) + await queryRunner.query( + "CREATE INDEX \"IDX_L2PS_PROOFS_STATUS\" ON \"l2ps_proofs\" (\"status\")", + ) + await queryRunner.query( + "CREATE INDEX \"IDX_L2PS_PROOFS_BLOCK\" ON \"l2ps_proofs\" (\"target_block_number\")", + ) + await queryRunner.query( + "CREATE INDEX \"IDX_L2PS_PROOFS_BATCH_HASH\" ON \"l2ps_proofs\" (\"l1_batch_hash\")", + ) + await queryRunner.query( + "CREATE INDEX \"IDX_L2PS_PROOFS_UID_STATUS\" ON \"l2ps_proofs\" (\"l2ps_uid\", \"status\")", + ) + + // ─── network_upgrades ──────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE "network_upgrades" ( + "id" SERIAL PRIMARY KEY, + "proposal_id" text NOT NULL, + "version" integer NOT NULL, + "proposer_public_key" text NOT NULL, + "proposed_parameters" jsonb NOT NULL, + "status" text NOT NULL, + "snapshot_block" integer NOT NULL, + "tally_block" integer NOT NULL, + "effective_at_block" integer NOT NULL, + "rationale" text NOT NULL, + "created_at" timestamp NOT NULL DEFAULT now(), + CONSTRAINT "uq_network_upgrades_proposal_id" UNIQUE ("proposal_id") + ) + `) + await queryRunner.query( + "CREATE INDEX \"idx_network_upgrades_proposal_id\" ON \"network_upgrades\" (\"proposal_id\")", + ) + await queryRunner.query( + "CREATE INDEX \"idx_network_upgrades_status\" ON \"network_upgrades\" (\"status\")", + ) + await queryRunner.query( + "CREATE INDEX \"idx_network_upgrades_effective_at_block\" ON \"network_upgrades\" (\"effective_at_block\")", + ) + + // ─── network_upgrade_votes ─────────────────────────────────── + await queryRunner.query(` + CREATE TABLE "network_upgrade_votes" ( + "id" SERIAL PRIMARY KEY, + "proposal_id" text NOT NULL, + "voter_address" text NOT NULL, + "approve" boolean NOT NULL, + "weight" text NOT NULL, + "block_number" integer NOT NULL, + "created_at" timestamp NOT NULL DEFAULT now(), + CONSTRAINT "uq_proposal_voter" UNIQUE ("proposal_id", "voter_address") + ) + `) + await queryRunner.query( + "CREATE INDEX \"idx_network_upgrade_votes_proposal_id\" ON \"network_upgrade_votes\" (\"proposal_id\")", + ) + + // ─── fork_state ────────────────────────────────────────────── + // P3b — persistent DEM→OS fork-activation ledger. + await queryRunner.query(` + CREATE TABLE "fork_state" ( + "fork_name" text PRIMARY KEY, + "applied" boolean NOT NULL DEFAULT false, + "applied_at_block" bigint, + "applied_at" timestamp, + "pre_sum_dem" text, + "post_sum_os" text, + "gcr_v2_row_count" integer, + "legacy_row_count" integer, + "validators_row_count" integer, + "capped_count" integer, + "total_value_lost_os" text, + "malformed_validators_count" integer + ) + `) + } + + public async down(queryRunner: QueryRunner): Promise { + // No foreign keys, so drop order doesn't matter — alphabetical + // for readability. + const tables = [ + "blocks", + "consensus", + "fork_state", + "gcr_assigned_txs", + "gcr_hashes", + "gcr_main", + "gcr_storageprogram", + "gcr_tlsnotary", + "gcr_subnets_txs", + "identity_commitments", + "l2ps_hashes", + "l2ps_mempool", + "l2ps_proofs", + "l2ps_transactions", + "mempooltx", + "merkle_tree_state", + "network_upgrade_votes", + "network_upgrades", + "offline_messages", + "pgp_key_server", + "transactions", + "used_nullifiers", + "validators", + ] + for (const t of tables) { + await queryRunner.query(`DROP TABLE "${t}"`) + } + } +} diff --git a/src/migrations/AddReferralSupport.ts b/src/migrations/AddReferralSupport.ts deleted file mode 100644 index ed9dda06a..000000000 --- a/src/migrations/AddReferralSupport.ts +++ /dev/null @@ -1,44 +0,0 @@ -// import { MigrationInterface, QueryRunner } from "typeorm" - -// export class AddReferralSupport1703123456789 implements MigrationInterface { -// public async up(queryRunner: QueryRunner): Promise { -// // Add referrals field to points breakdown for existing records -// await queryRunner.query(` -// UPDATE gcr_main -// SET points = json_set( -// points, -// '$.breakdown.referrals', -// 0 -// ) -// WHERE points IS NOT NULL -// AND json_extract(points, '$.breakdown') IS NOT NULL -// AND json_extract(points, '$.breakdown.referrals') IS NULL -// `) - -// // Add referralInfo field for existing records -// await queryRunner.query(` -// UPDATE gcr_main -// SET referralInfo = json_object( -// 'totalReferrals', 0, -// 'referralCode', pubkey, -// 'referrals', json_array() -// ) -// WHERE referralInfo IS NULL -// `) -// } - -// public async down(queryRunner: QueryRunner): Promise { -// // Remove referrals field from points breakdown -// await queryRunner.query(` -// UPDATE gcr_main -// SET points = json_remove(points, '$.breakdown.referrals') -// WHERE json_extract(points, '$.breakdown.referrals') IS NOT NULL -// `) - -// // Remove referralInfo field -// await queryRunner.query(` -// UPDATE gcr_main -// SET referralInfo = NULL -// `) -// } -// } diff --git a/src/migrations/AddRpcAddressToTransactions.ts b/src/migrations/AddRpcAddressToTransactions.ts deleted file mode 100644 index 63f13741c..000000000 --- a/src/migrations/AddRpcAddressToTransactions.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* LICENSE - -© 2026 by KyneSys Labs, licensed under CC BY-NC-ND 4.0 - -Full license text: https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode -Human readable license: https://creativecommons.org/licenses/by-nc-nd/4.0/ - -KyneSys Labs: https://www.kynesys.xyz/ - -*/ - -import { MigrationInterface, QueryRunner } from "typeorm" - -/** - * DEM-665 — Adds the `rpcAddress` column to the `transactions` table. - * - * The column stores the ed25519 public key (lowercase hex, `0x` + 64 hex - * chars) of the RPC node that validated each transaction. Post-fork the - * fee-distribution logic reads this to route the `rpc_fee` portion to - * the correct operator account. - * - * Nullable by design: pre-fork rows predate the field. Post-wipe chains - * should have no pre-fork rows, but the column shape stays defensive - * for any legacy/legacy-replay scenario. - * - * `synchronize: true` in `datasource.ts` would add the column - * automatically on first boot of a node carrying this code; this - * migration is checked in so production deployments can run it - * deterministically. - */ -export class AddRpcAddressToTransactions1714780800000 - implements MigrationInterface -{ - name = "AddRpcAddressToTransactions1714780800000" - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query( - 'ALTER TABLE "transactions" ADD COLUMN IF NOT EXISTS "rpcAddress" varchar NULL', - ) - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query( - 'ALTER TABLE "transactions" DROP COLUMN IF EXISTS "rpcAddress"', - ) - } -} diff --git a/src/migrations/CreateForkStateTable.ts b/src/migrations/CreateForkStateTable.ts deleted file mode 100644 index 7882240a9..000000000 --- a/src/migrations/CreateForkStateTable.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* LICENSE - -© 2026 by KyneSys Labs, licensed under CC BY-NC-ND 4.0 - -Full license text: https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode -Human readable license: https://creativecommons.org/licenses/by-nc-nd/4.0/ - -KyneSys Labs: https://www.kynesys.xyz/ - -*/ - -import { MigrationInterface, QueryRunner } from "typeorm" - -/** - * REVIEW - * Creates the `fork_state` table used by the P3b state migration to track - * one-time DEM → OS conversion per fork. Schema mirrors - * `src/model/entities/ForkState.ts`. - * - * `synchronize: true` in `datasource.ts` will create the table automatically - * on startup, but this migration is checked in so production deployments - * can run it deterministically (and so the schema is reviewable in PR diff - * form, not buried in TypeORM auto-sync logs). - * - * Idempotency: the table is created with `IF NOT EXISTS` so re-running the - * migration after `synchronize` is a no-op. - */ -export class CreateForkStateTable1714608000000 implements MigrationInterface { - name = "CreateForkStateTable1714608000000" - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `CREATE TABLE IF NOT EXISTS "fork_state" ( - "fork_name" text PRIMARY KEY, - "applied" boolean NOT NULL DEFAULT false, - "applied_at_block" bigint, - "applied_at" timestamp, - "pre_sum_dem" text, - "post_sum_os" text, - "gcr_v2_row_count" integer, - "legacy_row_count" integer, - "validators_row_count" integer, - "capped_count" integer, - "total_value_lost_os" text, - "malformed_validators_count" integer - )`, - ) - // Defensive add for environments where the table existed before - // this column was introduced; harmless if the column already exists. - await queryRunner.query( - "ALTER TABLE \"fork_state\" ADD COLUMN IF NOT EXISTS " + - "\"malformed_validators_count\" integer", - ) - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query("DROP TABLE IF EXISTS \"fork_state\"") - } -} diff --git a/src/migrations/WidenFeeColumnsToBigint.ts b/src/migrations/WidenFeeColumnsToBigint.ts deleted file mode 100644 index c21778d5c..000000000 --- a/src/migrations/WidenFeeColumnsToBigint.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* LICENSE - -© 2026 by KyneSys Labs, licensed under CC BY-NC-ND 4.0 - -Full license text: https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode -Human readable license: https://creativecommons.org/licenses/by-nc-nd/4.0/ - -KyneSys Labs: https://www.kynesys.xyz/ - -*/ - -import { MigrationInterface, QueryRunner } from "typeorm" - -/** - * REVIEW - * Widens the fee columns on the `transactions` table from `integer` (32-bit) - * to `bigint` (64-bit). The columns previously held DEM-denominated fees - * which fit in 32 bits today; widening makes them safe for OS-denominated - * fees once the DEM -> OS migration lands. This migration is a pure column - * widening: no row-level data conversion is required because every existing - * value already fits in `bigint`. - * - * `synchronize: true` in `datasource.ts` will perform the widening - * automatically on startup, but this migration is checked in so production - * deployments can run it deterministically. - */ -export class WidenFeeColumnsToBigint1714521600000 - implements MigrationInterface -{ - name = "WidenFeeColumnsToBigint1714521600000" - - public async up(queryRunner: QueryRunner): Promise { - // Widen type first. - await queryRunner.query( - "ALTER TABLE \"transactions\" ALTER COLUMN \"networkFee\" TYPE bigint USING \"networkFee\"::bigint", - ) - await queryRunner.query( - "ALTER TABLE \"transactions\" ALTER COLUMN \"rpcFee\" TYPE bigint USING \"rpcFee\"::bigint", - ) - await queryRunner.query( - "ALTER TABLE \"transactions\" ALTER COLUMN \"additionalFee\" TYPE bigint USING \"additionalFee\"::bigint", - ) - // Backfill any NULL rows from older databases that predate the - // entity declaration. Without this, a `synchronize: true` boot - // against a legacy DB fails when TypeORM tries to enforce NOT - // NULL on rows containing nulls. The entity itself declares the - // columns `nullable: true, default: 0` so the boot path doesn't - // depend on this migration having run, but the migration sets - // the DB-side default for deterministic prod deploys. - await queryRunner.query( - "UPDATE \"transactions\" SET \"networkFee\" = 0 WHERE \"networkFee\" IS NULL", - ) - await queryRunner.query( - "UPDATE \"transactions\" SET \"rpcFee\" = 0 WHERE \"rpcFee\" IS NULL", - ) - await queryRunner.query( - "UPDATE \"transactions\" SET \"additionalFee\" = 0 WHERE \"additionalFee\" IS NULL", - ) - await queryRunner.query( - "ALTER TABLE \"transactions\" ALTER COLUMN \"networkFee\" SET DEFAULT 0", - ) - await queryRunner.query( - "ALTER TABLE \"transactions\" ALTER COLUMN \"rpcFee\" SET DEFAULT 0", - ) - await queryRunner.query( - "ALTER TABLE \"transactions\" ALTER COLUMN \"additionalFee\" SET DEFAULT 0", - ) - } - - public async down(queryRunner: QueryRunner): Promise { - // Narrow back to integer. Will fail at runtime if any row holds a - // value > INT_MAX, which is the desired safety behavior. - await queryRunner.query( - "ALTER TABLE \"transactions\" ALTER COLUMN \"networkFee\" TYPE integer USING \"networkFee\"::integer", - ) - await queryRunner.query( - "ALTER TABLE \"transactions\" ALTER COLUMN \"rpcFee\" TYPE integer USING \"rpcFee\"::integer", - ) - await queryRunner.query( - "ALTER TABLE \"transactions\" ALTER COLUMN \"additionalFee\" TYPE integer USING \"additionalFee\"::integer", - ) - } -} diff --git a/src/migrations/WidenGcrMainBalanceToNumeric.ts b/src/migrations/WidenGcrMainBalanceToNumeric.ts deleted file mode 100644 index b6b7bc6ec..000000000 --- a/src/migrations/WidenGcrMainBalanceToNumeric.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* LICENSE - -© 2026 by KyneSys Labs, licensed under CC BY-NC-ND 4.0 - -Full license text: https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode -Human readable license: https://creativecommons.org/licenses/by-nc-nd/4.0/ - -KyneSys Labs: https://www.kynesys.xyz/ - -*/ - -import { MigrationInterface, QueryRunner } from "typeorm" - -/** - * REVIEW - * Widens `gcr_main.balance` from `bigint` (signed 64-bit, max ~9.22e18) to - * `numeric(38, 0)` (arbitrary precision, **integer-only**). This is required - * for the osDenomination fork migration — `UPDATE gcr_main SET balance = - * balance * 1000000000` overflows `bigint` on the production-genesis seed - * magnitudes (10^18 × 10^9 = 10^27, well past the signed-64 ceiling). - * Numeric has no fixed precision limit so the multiplication can complete - * in-place. - * - * **Integer-only invariant** (myc#85, GH#3213220467): the entity-level - * type is `bigint` and the application + ORM transformer assume integer - * values. A malformed raw SQL caller could otherwise write a fractional - * value, and the transformer's `from()` would feed it to `BigInt()` which - * rejects fractional strings. Pinning the column to `numeric(38, 0)` - * (zero scale) lets the database reject fractional writes at the - * column-type level, comfortably covering the 1e27 OS magnitude ceiling - * (38 decimal digits ≫ 28). The widening is the same diff as before — - * we just constrain the scale. - * - * The application reads `balance` as `bigint`; a TypeORM transformer - * (`bigintNumericTransformer` in `src/model/entities/transformers.ts`) - * preserves that type at the ORM boundary so existing call sites continue - * to compile and behave identically. Raw `entityManager.query` callers must - * coerce via `BigInt(row.balance)` — this was already the established - * convention for the previous `bigint` column (the pg driver returned it - * as a string too). - * - * The `USING balance::numeric(38, 0)` clause is required because Postgres - * won't implicitly cast `bigint` → `numeric(38, 0)` on a column type - * change. - * - * `synchronize: true` in `datasource.ts` will perform the widening - * automatically on startup, but this migration is checked in so production - * deployments can run it deterministically (and so the schema change is - * reviewable in PR diff form rather than buried in TypeORM auto-sync logs). - */ -export class WidenGcrMainBalanceToNumeric1714694400000 - implements MigrationInterface -{ - name = "WidenGcrMainBalanceToNumeric1714694400000" - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query( - "ALTER TABLE \"gcr_main\" ALTER COLUMN \"balance\" TYPE numeric(38, 0) USING \"balance\"::numeric(38, 0)", - ) - await queryRunner.query( - "ALTER TABLE \"gcr_main\" ALTER COLUMN \"balance\" SET DEFAULT '0'", - ) - } - - public async down(queryRunner: QueryRunner): Promise { - // Narrow back to bigint. Will fail at runtime if any row holds a - // value > BIGINT max (9.22e18) — which is the desired safety - // behavior: post-fork OS balances are 10^9× larger than DEM, so - // an account holding 10^10 DEM (= 10^19 OS) cannot be downcast - // without precision loss. Operators must reconcile such rows - // before reverting. - await queryRunner.query( - "ALTER TABLE \"gcr_main\" ALTER COLUMN \"balance\" TYPE bigint USING \"balance\"::bigint", - ) - } -} diff --git a/src/model/datasource.ts b/src/model/datasource.ts index 04f506a85..32594db98 100644 --- a/src/model/datasource.ts +++ b/src/model/datasource.ts @@ -9,6 +9,8 @@ KyneSys Labs: https://www.kynesys.xyz/ */ +import { dirname } from "node:path" +import { fileURLToPath } from "node:url" import { DataSource } from "typeorm" import { Config } from "src/config" @@ -17,14 +19,12 @@ import { Consensus } from "./entities/Consensus.js" import { MempoolTx } from "./entities/Mempool.js" import { PgpKeyServer } from "./entities/PgpKeyServer.js" import { Transactions } from "./entities/Transactions.js" -import { Validators } from "./entities/Validators.js" -import { GlobalChangeRegistry } from "./entities/GCR/GlobalChangeRegistry.js" import { GCRHashes } from "./entities/GCRv2/GCRHashes.js" import { GCRSubnetsTxs } from "./entities/GCRv2/GCRSubnetsTxs.js" import { GCRMain } from "./entities/GCRv2/GCR_Main.js" +import { GCRAssignedTx } from "./entities/GCRv2/GCRAssignedTx.js" import { GCRTLSNotary } from "./entities/GCRv2/GCR_TLSNotary.js" import { GCRStorageProgram } from "./entities/GCRv2/GCR_StorageProgram.js" -import { GCRTracker } from "./entities/GCR/GCRTracker.js" // ZK Identity entities import { IdentityCommitment } from "./entities/GCRv2/IdentityCommitment.js" import { UsedNullifier } from "./entities/GCRv2/UsedNullifier.js" @@ -40,6 +40,7 @@ import { NetworkUpgrade } from "./entities/NetworkUpgrade.js" import { NetworkUpgradeVote } from "./entities/NetworkUpgradeVote.js" // Hard-fork bookkeeping (P3b — DEM→OS denomination migration) import { ForkState } from "./entities/ForkState.js" +import { Validators } from "./entities/Validators.js" export const dataSource = new DataSource({ type: "postgres", @@ -48,7 +49,8 @@ export const dataSource = new DataSource({ username: Config.getInstance().database.user, password: Config.getInstance().database.password, database: Config.getInstance().database.database, - migrations: ["../migrations/*.{ts,js}"], + migrations: ["src/migrations/*.{ts,js}"], + migrationsRun: true, entities: [ Blocks, MempoolTx, @@ -57,12 +59,11 @@ export const dataSource = new DataSource({ GCRHashes, GCRSubnetsTxs, Transactions, - Validators, - GlobalChangeRegistry, - GCRTracker, GCRMain, + GCRAssignedTx, GCRTLSNotary, GCRStorageProgram, + Validators, // ZK Identity entities IdentityCommitment, UsedNullifier, @@ -79,7 +80,7 @@ export const dataSource = new DataSource({ // Hard-fork bookkeeping (P3b) ForkState, ], - synchronize: true, + synchronize: false, logging: false, }) diff --git a/src/model/entities/Blocks.ts b/src/model/entities/Blocks.ts index f01f4d87e..d3f79194a 100644 --- a/src/model/entities/Blocks.ts +++ b/src/model/entities/Blocks.ts @@ -1,3 +1,4 @@ +import { BlockContent } from "@kynesyslabs/demosdk/types" import { pki } from "node-forge" import { Column, Entity, Index, PrimaryGeneratedColumn } from "typeorm" @@ -15,7 +16,7 @@ export class Blocks { hash: string @Column("json", { name: "content" }) - content: NonNullable + content: NonNullable @Column("varchar", { name: "status" }) status: string @@ -27,5 +28,5 @@ export class Blocks { next_proposer: string @Column("json", { name: "validation_data" }) - validation_data: any + validation_data: NonNullable<{ signatures: { [signer: string]: string } }> } diff --git a/src/model/entities/GCR/GCRTracker.ts b/src/model/entities/GCR/GCRTracker.ts deleted file mode 100644 index 20cd617c7..000000000 --- a/src/model/entities/GCR/GCRTracker.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Column, Entity, PrimaryGeneratedColumn } from "typeorm" -import type { StoredIdentities } from "../types/IdentityTypes" - -/* NOTE - This table is used to track the changes in the GCR. - Each time a GlobalChangeRegistry is updated, the corresponding updated hash is stored in this table. - This is used to know if the GCR has changed or not when querying the GCR. -*/ - -// SECTION Entities - -@Entity("gcr_tracker") -export class GCRTracker { - @PrimaryGeneratedColumn({ type: "integer", name: "id" }) - id: number - - @Column("text", { name: "public_key" }) - publicKey: string - - @Column("text", { name: "hash" }) - hash: string -} diff --git a/src/model/entities/GCR/GlobalChangeRegistry.ts b/src/model/entities/GCR/GlobalChangeRegistry.ts deleted file mode 100644 index 5b49bd27e..000000000 --- a/src/model/entities/GCR/GlobalChangeRegistry.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Column, Entity, PrimaryGeneratedColumn } from "typeorm" -import type { StoredIdentities } from "../types/IdentityTypes" -// Define the shape of your JSON data - -// ! See https://poe.com/s/ud8vmCkiIHiNCaU7SYaP for efficient JSONB manipulation (and apply around the code) - -// SECTION Interfaces - -export interface GCRStatus { - hash: string - content: { - balance: number // balance of the address - identities: StoredIdentities // Identities that are linked to this address - txs: string[] // hashes of the transactions pertaining to this address - nonce: number // last nonce used by this address - } -} - -export interface GCRExtended { - tokens: string[] - nfts: string[] - xm: string[] - web2: string[] - other: string[] -} - -// SECTION Entities - -@Entity("global_change_registry") -export class GlobalChangeRegistry { - @PrimaryGeneratedColumn({ type: "integer", name: "id" }) - id: number - - @Column("text", { name: "public_key" }) - publicKey: string - - @Column("jsonb", { name: "details" }) - details: GCRStatus - - @Column("jsonb", { name: "extended" }) - extended: GCRExtended -} diff --git a/src/model/entities/GCRv2/GCRAssignedTx.ts b/src/model/entities/GCRv2/GCRAssignedTx.ts new file mode 100644 index 000000000..744db3127 --- /dev/null +++ b/src/model/entities/GCRv2/GCRAssignedTx.ts @@ -0,0 +1,30 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryColumn, +} from "typeorm" + +/** + * Records which transactions have been assigned to which GCR account. + * + * Replaces the `assignedTxs` jsonb array that used to live on `gcr_main`. + * Moving this out eliminates the TOAST write-amplification that came from + * appending to a growing jsonb column on every block. + */ +@Entity("gcr_assigned_txs") +@Index("idx_gcr_assigned_txs_pubkey", ["pubkey", "blockNumber"]) +export class GCRAssignedTx { + @PrimaryColumn({ type: "text", name: "pubkey" }) + pubkey: string + + @PrimaryColumn({ type: "text", name: "tx_hash" }) + txHash: string + + @Column({ type: "integer", name: "block_number", default: 0 }) + blockNumber: number + + @CreateDateColumn({ type: "timestamptz", name: "assigned_at" }) + assignedAt: Date +} diff --git a/src/model/entities/GCRv2/GCRSubnetsTxs.ts b/src/model/entities/GCRv2/GCRSubnetsTxs.ts index 97c4c9e59..0e9217aa2 100644 --- a/src/model/entities/GCRv2/GCRSubnetsTxs.ts +++ b/src/model/entities/GCRv2/GCRSubnetsTxs.ts @@ -6,7 +6,7 @@ import type { L2PSTransaction } from "@kynesyslabs/demosdk/types" This allows for a quick lookup to the transaction details and the block they were included in. */ -@Entity("global_change_registry_subnets_txs") +@Entity("gcr_subnets_txs") export class GCRSubnetsTxs { @PrimaryColumn("text", { name: "tx_hash" }) tx_hash: string diff --git a/src/model/entities/GCRv2/GCR_Main.ts b/src/model/entities/GCRv2/GCR_Main.ts index bbc6cd08b..a26d9ffba 100644 --- a/src/model/entities/GCRv2/GCR_Main.ts +++ b/src/model/entities/GCRv2/GCR_Main.ts @@ -15,8 +15,6 @@ import { bigintNumericTransformer } from "../transformers" export class GCRMain { @PrimaryColumn({ type: "text", name: "pubkey" }) pubkey: string - @Column({ type: "jsonb", name: "assignedTxs" }) - assignedTxs: string[] @Column({ type: "integer", name: "nonce" }) nonce: number /** diff --git a/src/utilities/constants.ts b/src/utilities/constants.ts index c08c296bd..92fe1a4f4 100644 --- a/src/utilities/constants.ts +++ b/src/utilities/constants.ts @@ -12,7 +12,7 @@ export const DEFAULT_SIGNING_ALGORITHM = "ed25519" as const // --- Consensus / Block timing --- /** Default block time in seconds (should come from genesis in the future) */ -export const DEFAULT_BLOCK_TIME = 20 +export const DEFAULT_BLOCK_TIME = 10 // --- Peer management --- /** How often to recheck peers (ms) */ diff --git a/src/utilities/mainLoop.ts b/src/utilities/mainLoop.ts index dac4fdcc1..7f9980ca7 100644 --- a/src/utilities/mainLoop.ts +++ b/src/utilities/mainLoop.ts @@ -30,7 +30,6 @@ export default async function mainLoop() { } finally { // Reset flags getSharedState.inMainLoop = false - getSharedState.inSyncLoop = false getSharedState.inPeerRecheckLoop = false await sleep(getSharedState.mainLoopSleepTime) } @@ -92,7 +91,7 @@ async function mainLoopCycle() { // await peerGossip() log.info("[MAINLOOP]: Running Sync routine") - fastSync([], "mainloop") // REVIEW Test here + // await fastSync([], "mainloop") // REVIEW Test here // await yieldToEventLoop() // we now have a list of online peers that can be used for consensus diff --git a/src/utilities/tui/LegacyLoggerAdapter.ts b/src/utilities/tui/LegacyLoggerAdapter.ts index 4282c8db6..0773fafa6 100644 --- a/src/utilities/tui/LegacyLoggerAdapter.ts +++ b/src/utilities/tui/LegacyLoggerAdapter.ts @@ -253,6 +253,8 @@ export default class LegacyLoggerAdapter { private static originalLog: typeof console.log | null = null static only(message: unknown, padWithNewLines = false): void { + return this.debug(message, padWithNewLines) + const stringifiedMessage = stringify(message) if (!this.LOG_ONLY_ENABLED) { this.logger.debug("CORE", "[LOG ONLY ENABLED]") diff --git a/worker-threads-test/main.ts b/worker-threads-test/main.ts new file mode 100644 index 000000000..4925cde90 --- /dev/null +++ b/worker-threads-test/main.ts @@ -0,0 +1,127 @@ +import os from "node:os"; +import { randomBytes } from "node:crypto"; + +const NUM_STRINGS = 1000; +const STRING_SIZE_BYTES = 100 * 1024; +const BATCH_SIZE = 25; +const SHUTDOWN_TIMEOUT_MS = 2000; + +type BatchMessage = { type: "batch"; batchId: number; items: string[] }; +type ShutdownMessage = { type: "shutdown" }; +type BatchResult = { type: "batch-result"; batchId: number; hashes: string[] }; + +const numWorkers = Math.max(1, os.cpus().length - 1); +const workerUrl = new URL("./worker.ts", import.meta.url); + +console.log(`Spawning ${numWorkers} workers...`); +const workers = Array.from( + { length: numWorkers }, + () => new Worker(workerUrl.href), +); + +let shuttingDown = false; +async function shutdown(): Promise { + if (shuttingDown) return; + shuttingDown = true; + console.log("Shutting down workers..."); + await Promise.all( + workers.map( + (w) => + new Promise((resolve) => { + let settled = false; + const done = () => { + if (settled) return; + settled = true; + resolve(); + }; + w.addEventListener("close", done, { once: true }); + try { + const msg: ShutdownMessage = { type: "shutdown" }; + w.postMessage(msg); + } catch { + done(); + return; + } + setTimeout(() => { + w.terminate(); + done(); + }, SHUTDOWN_TIMEOUT_MS).unref(); + }), + ), + ); + console.log("All workers shut down."); +} + +process.on("SIGINT", async () => { + console.log("\nReceived SIGINT."); + await shutdown(); + process.exit(130); +}); + +console.log( + `Generating ${NUM_STRINGS} x ${STRING_SIZE_BYTES / 1024}KB random strings...`, +); +const tGenStart = Bun.nanoseconds(); +const strings: string[] = new Array(NUM_STRINGS); +for (let i = 0; i < NUM_STRINGS; i++) { + strings[i] = randomBytes(STRING_SIZE_BYTES / 2).toString("hex"); +} +const genMs = (Bun.nanoseconds() - tGenStart) / 1e6; +console.log(`Generation: ${genMs.toFixed(2)}ms`); + +const batches: string[][] = []; +for (let i = 0; i < strings.length; i += BATCH_SIZE) { + batches.push(strings.slice(i, i + BATCH_SIZE)); +} + +console.log( + `Dispatching ${batches.length} batches of ${BATCH_SIZE} across ${numWorkers} workers...`, +); + +let nextBatch = 0; +let completed = 0; +const allHashes: string[][] = new Array(batches.length); + +const tHashStart = Bun.nanoseconds(); +await new Promise((resolve) => { + const dispatchTo = (worker: Worker) => { + if (nextBatch >= batches.length) return; + const batchId = nextBatch++; + const msg: BatchMessage = { + type: "batch", + batchId, + items: batches[batchId], + }; + worker.postMessage(msg); + }; + + for (const w of workers) { + w.addEventListener("message", (e: MessageEvent) => { + const data = e.data as BatchResult; + if (data?.type !== "batch-result") return; + allHashes[data.batchId] = data.hashes; + completed++; + if (completed === batches.length) { + resolve(); + } else { + dispatchTo(w); + } + }); + dispatchTo(w); + } +}); +const hashMs = (Bun.nanoseconds() - tHashStart) / 1e6; + +const totalHashes = allHashes.reduce( + (sum, batch) => sum + (batch?.length ?? 0), + 0, +); + +console.log(`Hashing: ${hashMs.toFixed(2)}ms`); +console.log(`Total: ${(genMs + hashMs).toFixed(2)}ms`); +console.log( + `Throughput: ${(NUM_STRINGS / (hashMs / 1000)).toFixed(0)} hashes/sec`, +); +console.log(`Verified: ${totalHashes}/${NUM_STRINGS} hashes received`); + +await shutdown(); diff --git a/worker-threads-test/package.json b/worker-threads-test/package.json new file mode 100644 index 000000000..7f8603815 --- /dev/null +++ b/worker-threads-test/package.json @@ -0,0 +1,9 @@ +{ + "name": "worker-threads-test", + "private": true, + "type": "module", + "scripts": { + "test:workers": "bun run main.ts", + "test:workers:signverify": "bun run sign-verify.ts" + } +} diff --git a/worker-threads-test/sign-verify.ts b/worker-threads-test/sign-verify.ts new file mode 100644 index 000000000..a5a0dc7b3 --- /dev/null +++ b/worker-threads-test/sign-verify.ts @@ -0,0 +1,228 @@ +import os from "node:os"; +import { randomBytes } from "node:crypto"; +import { ucrypto, uint8ArrayToHex } from "@kynesyslabs/demosdk/encryption"; + +const NUM_STRINGS = 1000; +const STRING_SIZE_BYTES = 100 * 1024; +const BATCH_SIZE = 25; +const SHUTDOWN_TIMEOUT_MS = 2000; + +type VerifyItem = { hashHex: string; sigHex: string }; +type BatchMessage = { + type: "batch"; + batchId: number; + pubKeyHex: string; + items: VerifyItem[]; +}; +type ShutdownMessage = { type: "shutdown" }; +type BatchResult = { + type: "batch-result"; + batchId: number; + verified: number; + failed: number; +}; + +const numWorkers = Math.max(1, os.cpus().length - 1); +const workerUrl = new URL("./sign-verify.worker.ts", import.meta.url); + +console.log(`Spawning ${numWorkers} workers...`); +const workerReady = new Array>(numWorkers); +const workers = Array.from({ length: numWorkers }, (_, i) => { + const w = new Worker(workerUrl.href); + workerReady[i] = new Promise((resolve) => { + const onReadyMsg = (e: MessageEvent) => { + if ((e.data as any)?.type === "ready") { + console.log(`[main] worker ${i} ready`); + w.removeEventListener("message", onReadyMsg); + resolve(); + } + }; + w.addEventListener("message", onReadyMsg); + }); + w.addEventListener("error", (e: ErrorEvent) => { + console.error( + `[main] worker ${i} error event:`, + e.message ?? e, + (e as any).error?.stack ?? "", + ); + }); + w.addEventListener("messageerror", (e: MessageEvent) => { + console.error(`[main] worker ${i} messageerror:`, e.data ?? e); + }); + w.addEventListener("close", (e: CloseEvent) => { + console.error(`[main] worker ${i} close (code=${e.code})`); + }); + w.addEventListener("open", () => { + console.log(`[main] worker ${i} open`); + }); + return w; +}); + +console.log("Waiting for all workers to import demosdk and signal ready..."); +const tReadyStart = Bun.nanoseconds(); +const readyTimeoutMs = 60_000; +const readyTimer = setTimeout(() => { + console.error( + `[main] TIMEOUT: workers not ready after ${readyTimeoutMs / 1000}s. Likely import hang.`, + ); +}, readyTimeoutMs); +readyTimer.unref(); +await Promise.all(workerReady); +clearTimeout(readyTimer); +console.log( + `All workers ready in ${((Bun.nanoseconds() - tReadyStart) / 1e6).toFixed(2)}ms`, +); + +let shuttingDown = false; +async function shutdown(): Promise { + if (shuttingDown) return; + shuttingDown = true; + console.log("Shutting down workers..."); + await Promise.all( + workers.map( + (w) => + new Promise((resolve) => { + let settled = false; + const done = () => { + if (settled) return; + settled = true; + resolve(); + }; + w.addEventListener("close", done, { once: true }); + try { + const msg: ShutdownMessage = { type: "shutdown" }; + w.postMessage(msg); + } catch { + done(); + return; + } + setTimeout(() => { + w.terminate(); + done(); + }, SHUTDOWN_TIMEOUT_MS).unref(); + }), + ), + ); + console.log("All workers shut down."); +} + +process.on("SIGINT", async () => { + console.log("\nReceived SIGINT."); + await shutdown(); + process.exit(130); +}); + +console.log("Generating ed25519 identity..."); +const tIdStart = Bun.nanoseconds(); +await ucrypto.generateIdentity("ed25519"); +const identity = await ucrypto.getIdentity("ed25519"); +const pubKeyHex = uint8ArrayToHex(identity.publicKey as Uint8Array); +const idMs = (Bun.nanoseconds() - tIdStart) / 1e6; +console.log(`Identity: ${idMs.toFixed(2)}ms (pubkey ${pubKeyHex.slice(0, 18)}…)`); + +console.log( + `Generating ${NUM_STRINGS} x ${STRING_SIZE_BYTES / 1024}KB random strings...`, +); +const tGenStart = Bun.nanoseconds(); +const strings: string[] = new Array(NUM_STRINGS); +for (let i = 0; i < NUM_STRINGS; i++) { + strings[i] = randomBytes(STRING_SIZE_BYTES / 2).toString("hex"); +} +const genMs = (Bun.nanoseconds() - tGenStart) / 1e6; +console.log(`Generation: ${genMs.toFixed(2)}ms`); + +console.log(`Hashing ${NUM_STRINGS} strings (sha256)...`); +const tHashStart = Bun.nanoseconds(); +const hashes: string[] = new Array(NUM_STRINGS); +for (let i = 0; i < NUM_STRINGS; i++) { + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(strings[i]); + hashes[i] = hasher.digest("hex"); +} +const hashMs = (Bun.nanoseconds() - tHashStart) / 1e6; +console.log(`Hashing: ${hashMs.toFixed(2)}ms`); + +console.log(`Signing ${NUM_STRINGS} hashes (ed25519, main thread)...`); +const tSignStart = Bun.nanoseconds(); +const sigs: string[] = new Array(NUM_STRINGS); +const encoder = new TextEncoder(); +for (let i = 0; i < NUM_STRINGS; i++) { + const signed = await ucrypto.sign("ed25519", encoder.encode(hashes[i])); + sigs[i] = uint8ArrayToHex(signed.signature); +} +const signMs = (Bun.nanoseconds() - tSignStart) / 1e6; +console.log(`Signing: ${signMs.toFixed(2)}ms`); + +const items: VerifyItem[] = hashes.map((hashHex, i) => ({ + hashHex, + sigHex: sigs[i], +})); +const batches: VerifyItem[][] = []; +for (let i = 0; i < items.length; i += BATCH_SIZE) { + batches.push(items.slice(i, i + BATCH_SIZE)); +} + +console.log( + `Dispatching ${batches.length} batches of ${BATCH_SIZE} across ${numWorkers} workers...`, +); + +let nextBatch = 0; +let completed = 0; +let totalVerified = 0; +let totalFailed = 0; + +const tVerifyStart = Bun.nanoseconds(); +await new Promise((resolve) => { + const dispatchTo = (worker: Worker) => { + if (nextBatch >= batches.length) return; + const batchId = nextBatch++; + const msg: BatchMessage = { + type: "batch", + batchId, + pubKeyHex, + items: batches[batchId], + }; + worker.postMessage(msg); + }; + + workers.forEach((w, idx) => { + w.addEventListener("message", (e: MessageEvent) => { + const data = e.data as + | BatchResult + | { type: "worker-error"; batchId?: number; message: string; stack?: string }; + if (data?.type === "worker-error") { + console.error( + `[main] worker ${idx} reported error (batch ${data.batchId ?? "n/a"}):`, + data.message, + ); + if (data.stack) console.error(data.stack); + return; + } + if (data?.type !== "batch-result") return; + totalVerified += data.verified; + totalFailed += data.failed; + completed++; + if (completed === batches.length) { + resolve(); + } else { + dispatchTo(w); + } + }); + dispatchTo(w); + }); +}); +const verifyMs = (Bun.nanoseconds() - tVerifyStart) / 1e6; + +const totalMs = idMs + genMs + hashMs + signMs + verifyMs; + +console.log(`Verifying: ${verifyMs.toFixed(2)}ms (${numWorkers} workers)`); +console.log(`Total: ${totalMs.toFixed(2)}ms`); +console.log( + `Sign rate: ${(NUM_STRINGS / (signMs / 1000)).toFixed(0)} sigs/sec (main)`, +); +console.log( + `Verify rate: ${(NUM_STRINGS / (verifyMs / 1000)).toFixed(0)} verifs/sec (parallel)`, +); +console.log(`Verified: ${totalVerified}/${NUM_STRINGS} (${totalFailed} failed)`); + +await shutdown(); diff --git a/worker-threads-test/sign-verify.worker.ts b/worker-threads-test/sign-verify.worker.ts new file mode 100644 index 000000000..359de1a43 --- /dev/null +++ b/worker-threads-test/sign-verify.worker.ts @@ -0,0 +1,109 @@ +declare var self: Worker; + +const workerId = Math.random().toString(36).slice(2, 6); +console.log(`[worker ${workerId}] booted, importing demosdk encryption...`); + +const t0 = Bun.nanoseconds(); +// Bypass @kynesyslabs/demosdk/encryption (the index re-exports zK and FHE, which +// transitively pull in ffjavascript → web-worker. web-worker's startup path +// crashes inside Bun Workers because Bun doesn't populate node:worker_threads +// `workerData` the way the polyfill expects, killing the worker before our +// handlers can run.) Loading unifiedCrypto.js directly skips both modules. +const unifiedCryptoUrl = new URL( + "../node_modules/@kynesyslabs/demosdk/build/encryption/unifiedCrypto.js", + import.meta.url, +); +const { unifiedCrypto: ucrypto, hexToUint8Array } = await import( + unifiedCryptoUrl.href +); +const importMs = (Bun.nanoseconds() - t0) / 1e6; +console.log( + `[worker ${workerId}] unifiedCrypto imported in ${importMs.toFixed(1)}ms`, +); + +type VerifyItem = { hashHex: string; sigHex: string }; +type BatchMessage = { + type: "batch"; + batchId: number; + pubKeyHex: string; + items: VerifyItem[]; +}; +type ShutdownMessage = { type: "shutdown" }; +type BatchResult = { + type: "batch-result"; + batchId: number; + verified: number; + failed: number; +}; +type ErrorResult = { + type: "worker-error"; + batchId?: number; + message: string; + stack?: string; +}; + +const postError = (err: unknown, batchId?: number) => { + const e = err as Error; + console.error(`[worker ${workerId}] error:`, e?.message ?? err); + const payload: ErrorResult = { + type: "worker-error", + batchId, + message: e?.message ?? String(err), + stack: e?.stack, + }; + try { + postMessage(payload); + } catch {} +}; + +process.on?.("uncaughtException", (err) => postError(err)); +process.on?.("unhandledRejection", (err) => postError(err)); + +self.onmessage = async ( + event: MessageEvent, +) => { + const msg = event.data; + + if (msg.type === "shutdown") { + console.log(`[worker ${workerId}] shutdown received`); + process.exit(0); + } + + if (msg.type === "batch") { + console.log( + `[worker ${workerId}] received batch ${msg.batchId} (${msg.items.length} items)`, + ); + try { + const publicKey = hexToUint8Array(msg.pubKeyHex); + let verified = 0; + let failed = 0; + + for (const item of msg.items) { + const ok = await ucrypto.verify({ + algorithm: "ed25519", + message: new TextEncoder().encode(item.hashHex), + publicKey, + signature: hexToUint8Array(item.sigHex), + } as any); + if (ok) verified++; + else failed++; + } + + const result: BatchResult = { + type: "batch-result", + batchId: msg.batchId, + verified, + failed, + }; + console.log( + `[worker ${workerId}] sending result for batch ${msg.batchId} (${verified}/${msg.items.length})`, + ); + postMessage(result); + } catch (err) { + postError(err, msg.batchId); + } + } +}; + +postMessage({ type: "ready", workerId }); +console.log(`[worker ${workerId}] handler registered, ready signal sent`); diff --git a/worker-threads-test/worker.ts b/worker-threads-test/worker.ts new file mode 100644 index 000000000..cac600c17 --- /dev/null +++ b/worker-threads-test/worker.ts @@ -0,0 +1,34 @@ +declare var self: Worker; + +type JobMessage = + | { type: "batch"; batchId: number; items: string[] } + | { type: "shutdown" }; + +type JobResult = { + type: "batch-result"; + batchId: number; + hashes: string[]; +}; + +self.onmessage = (event: MessageEvent) => { + const msg = event.data; + + if (msg.type === "shutdown") { + process.exit(0); + } + + if (msg.type === "batch") { + const hashes = new Array(msg.items.length); + for (let i = 0; i < msg.items.length; i++) { + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(msg.items[i]); + hashes[i] = hasher.digest("hex"); + } + const result: JobResult = { + type: "batch-result", + batchId: msg.batchId, + hashes, + }; + postMessage(result); + } +};