From 4ff97a26f0d3bb722ca195fb573e53a3141ce259 Mon Sep 17 00:00:00 2001 From: air1one <36802613+air1one@users.noreply.github.com> Date: Fri, 10 Apr 2020 09:00:51 +0400 Subject: [PATCH] feat(core-p2p): hapi/nes implementation (#3643) --- __tests__/e2e/lib/config/docker-compose.yml | 2 + __tests__/e2e/lib/config/nodes/core0/app.json | 4 +- .../e2e/lib/config/nodes/core0/peers.json | 3 +- __tests__/unit/core-forger/client.test.ts | 287 ++++----- .../unit/core-forger/forger-service.test.ts | 508 ++++++++------- __tests__/unit/core-forger/mocks/nes.ts | 11 + .../unit/core-forger/service-provider.test.ts | 589 +++--------------- jest.config.js | 1 - packages/core-forger/package.json | 6 +- packages/core-forger/src/client.ts | 58 +- packages/core-forger/src/interfaces.ts | 6 +- .../src/contracts/p2p/network-monitor.ts | 4 - .../src/contracts/p2p/peer-connector.ts | 10 +- .../providers/service-provider-repository.ts | 8 +- packages/core-p2p/package.json | 14 +- packages/core-p2p/src/actions/index.ts | 2 +- .../src/actions/validate-and-accept-peer.ts | 4 +- packages/core-p2p/src/defaults.ts | 3 +- packages/core-p2p/src/event-listener.ts | 8 - packages/core-p2p/src/network-monitor.ts | 36 +- packages/core-p2p/src/peer-communicator.ts | 31 +- packages/core-p2p/src/peer-connector.ts | 99 ++- packages/core-p2p/src/peer-verifier.ts | 2 +- packages/core-p2p/src/schemas.ts | 2 +- packages/core-p2p/src/service-provider.ts | 55 +- .../socket-server/controllers/controller.ts | 19 + .../src/socket-server/controllers/internal.ts | 94 +++ .../src/socket-server/controllers/peer.ts | 166 +++++ packages/core-p2p/src/socket-server/index.ts | 108 ---- .../src/socket-server/payload-processor.ts | 135 ---- .../src/socket-server/plugins/accept-peer.ts | 27 + .../src/socket-server/plugins/validate.ts | 30 + .../src/socket-server/routes/internal.ts | 36 ++ .../core-p2p/src/socket-server/routes/peer.ts | 46 ++ .../src/socket-server/routes/route.ts | 41 ++ .../src/socket-server/schemas/internal.ts | 8 + .../src/socket-server/schemas/peer.ts | 29 + packages/core-p2p/src/socket-server/server.ts | 110 ++++ .../src/socket-server/utils/validate.ts | 63 -- .../src/socket-server/versions/index.ts | 5 - .../src/socket-server/versions/internal.ts | 143 ----- .../src/socket-server/versions/peer.ts | 177 ------ .../src/socket-server/versions/utils.ts | 59 -- packages/core-p2p/src/socket-server/worker.ts | 376 ----------- packages/core-p2p/src/utils/index.ts | 2 - packages/core-p2p/src/utils/sc-codec.ts | 95 --- packages/core-p2p/src/utils/socket.ts | 55 -- .../src/actions/build-delegate-ranking.ts | 3 +- 48 files changed, 1238 insertions(+), 2342 deletions(-) create mode 100644 __tests__/unit/core-forger/mocks/nes.ts create mode 100644 packages/core-p2p/src/socket-server/controllers/controller.ts create mode 100644 packages/core-p2p/src/socket-server/controllers/internal.ts create mode 100644 packages/core-p2p/src/socket-server/controllers/peer.ts delete mode 100644 packages/core-p2p/src/socket-server/index.ts delete mode 100644 packages/core-p2p/src/socket-server/payload-processor.ts create mode 100644 packages/core-p2p/src/socket-server/plugins/accept-peer.ts create mode 100644 packages/core-p2p/src/socket-server/plugins/validate.ts create mode 100644 packages/core-p2p/src/socket-server/routes/internal.ts create mode 100644 packages/core-p2p/src/socket-server/routes/peer.ts create mode 100644 packages/core-p2p/src/socket-server/routes/route.ts create mode 100644 packages/core-p2p/src/socket-server/schemas/internal.ts create mode 100644 packages/core-p2p/src/socket-server/schemas/peer.ts create mode 100644 packages/core-p2p/src/socket-server/server.ts delete mode 100644 packages/core-p2p/src/socket-server/versions/index.ts delete mode 100644 packages/core-p2p/src/socket-server/versions/internal.ts delete mode 100644 packages/core-p2p/src/socket-server/versions/peer.ts delete mode 100644 packages/core-p2p/src/socket-server/versions/utils.ts delete mode 100644 packages/core-p2p/src/socket-server/worker.ts delete mode 100644 packages/core-p2p/src/utils/sc-codec.ts delete mode 100644 packages/core-p2p/src/utils/socket.ts diff --git a/__tests__/e2e/lib/config/docker-compose.yml b/__tests__/e2e/lib/config/docker-compose.yml index f5316bf28f..b238d156af 100644 --- a/__tests__/e2e/lib/config/docker-compose.yml +++ b/__tests__/e2e/lib/config/docker-compose.yml @@ -45,6 +45,8 @@ services: depends_on: - postgres1 - peerdiscovery + ports: + - 4000:4000 environment: <<: *coreEnvironment CORE_DB_HOST: postgres1 diff --git a/__tests__/e2e/lib/config/nodes/core0/app.json b/__tests__/e2e/lib/config/nodes/core0/app.json index fca119cd50..cf086ba9a4 100644 --- a/__tests__/e2e/lib/config/nodes/core0/app.json +++ b/__tests__/e2e/lib/config/nodes/core0/app.json @@ -22,7 +22,7 @@ { "package": "@arkecosystem/core-p2p", "options": { - "minimumNetworkReach": 3 + "minimumNetworkReach": 1 } }, { @@ -65,7 +65,7 @@ { "package": "@arkecosystem/core-p2p", "options": { - "minimumNetworkReach": 3 + "minimumNetworkReach": 1 } }, { diff --git a/__tests__/e2e/lib/config/nodes/core0/peers.json b/__tests__/e2e/lib/config/nodes/core0/peers.json index 0133550d9c..e3f547c487 100644 --- a/__tests__/e2e/lib/config/nodes/core0/peers.json +++ b/__tests__/e2e/lib/config/nodes/core0/peers.json @@ -1,4 +1,3 @@ { - "list": [], - "sources": ["http://peerdiscovery:3000/"] + "list": [ { "ip": "127.0.0.1", "port": 4000 }] } diff --git a/__tests__/unit/core-forger/client.test.ts b/__tests__/unit/core-forger/client.test.ts index 0217f12b15..a08ce25de0 100644 --- a/__tests__/unit/core-forger/client.test.ts +++ b/__tests__/unit/core-forger/client.test.ts @@ -3,12 +3,12 @@ import "jest-extended"; import { Client } from "@packages/core-forger/src/client"; import { Application, Container } from "@packages/core-kernel"; import { NetworkStateStatus } from "@packages/core-p2p"; -import { codec } from "@packages/core-p2p"; -import socketCluster from "socketcluster-client"; import { forgedBlockWithTransactions } from "./__utils__/create-block-with-transactions"; -jest.mock("socketcluster-client"); +import Nes, { nesClient } from "./mocks/nes"; + +jest.mock("@hapi/nes", () => require("./mocks/nes")); let app: Application; const logger = { @@ -22,151 +22,93 @@ beforeEach(() => { }); afterEach(() => { - jest.restoreAllMocks(); - jest.resetAllMocks(); + //jest.resetAllMocks(); }); describe("Client", () => { - let spySocketDisconnect; - let spySocketOn; - let spySocketCluster; - let spyEmit; - - let mockHost; let client: Client; + const host = { hostname: "127.0.0.1", port: 4000, socket: undefined }; + const hosts = [ host ]; + beforeEach(() => { client = app.resolve(Client); - spySocketOn = jest.fn(); - spySocketDisconnect = jest.fn(); - spyEmit = jest.fn((__, data, cb) => cb(undefined, data)); - - mockHost = { - socket: { - on: spySocketOn, - disconnect: spySocketDisconnect, - emit: spyEmit, - getState: () => "open", - OPEN: "open", - }, - hostname: "mock-1", - }; - // @ts-ignore - spySocketCluster = jest.spyOn(socketCluster, "create").mockImplementation(() => mockHost.socket); + + logger.error.mockReset(); + logger.debug.mockReset(); }); describe("register", () => { it("should register hosts", async () => { - const expected = { - ...mockHost, - autoReconnectOptions: { - initialDelay: 1000, - maxDelay: 1000, - }, - codecEngine: codec, - }; - - client.register([mockHost]); - expect(spySocketCluster).toHaveBeenCalledWith(expected); - expect(client.hosts).toEqual([mockHost]); + client.register(hosts); + expect(Nes.Client).toHaveBeenCalledWith(`ws://${host.hostname}:${host.port}`); + expect(client.hosts).toEqual([ { ...host, socket: expect.anything() } ]); }); it("on error the socket should call logger", () => { - let onErrorCallBack; - spySocketOn.mockImplementation((...data) => (onErrorCallBack = data[1])); - - client.register([mockHost]); + client.register(hosts); const fakeError = { message: "Fake Error" }; - onErrorCallBack(fakeError); + client.hosts[0].socket.onError(fakeError); expect(logger.error).toHaveBeenCalledWith("Fake Error"); }); - - it("should not call logger if the socket hangs up", () => { - let onErrorCallBack; - spySocketOn.mockImplementationOnce((...data) => (onErrorCallBack = data[1])); - - client.register([mockHost]); - - const socketHangupError = { message: "Socket hung up" }; - onErrorCallBack(socketHangupError); - - expect(logger.error).not.toHaveBeenCalled(); - }); }); describe("dispose", () => { it("should call disconnect on all sockets", () => { - client.register([mockHost]); + client.register([ host, { hostname: "127.0.0.5", port: 4000 }]); client.dispose(); - expect(spySocketDisconnect).toHaveBeenCalled(); - }); - - it("should do nothing if a hosts socket doesn't exist", () => { - client.register([mockHost]); - delete mockHost.socket; - - client.dispose(); - expect(spySocketDisconnect).not.toHaveBeenCalledWith(); + expect(client.hosts[0].socket.disconnect).toHaveBeenCalled(); + expect(client.hosts[1].socket.disconnect).toHaveBeenCalled(); + expect(nesClient.disconnect).toBeCalled(); }); }); describe("broadcastBlock", () => { it("should log broadcast as debug message", async () => { - client.register([mockHost]); + client.register(hosts); await expect(client.broadcastBlock(forgedBlockWithTransactions)).toResolve(); expect(logger.debug).toHaveBeenCalledWith( `Broadcasting block ${forgedBlockWithTransactions.data.height.toLocaleString()} (${ forgedBlockWithTransactions.data.id - }) with ${forgedBlockWithTransactions.data.numberOfTransactions} transactions to ${mockHost.hostname}`, + }) with ${forgedBlockWithTransactions.data.numberOfTransactions} transactions to ${host.hostname}`, ); }); it("should not broadcast block when there is an issue with socket", async () => { - client.register([mockHost]); + client.register(hosts); - mockHost.socket = {}; + host.socket = {}; await expect(client.broadcastBlock(forgedBlockWithTransactions)).toResolve(); expect(logger.error).toHaveBeenCalledWith( - `Broadcast block failed: Request to ${mockHost.hostname}:${mockHost.port} failed, because of 'socket.getState is not a function'.`, - ); - }); - - it("should not broadcast block when there the socket is closed", async () => { - client.register([mockHost]); - - mockHost.socket.getState = () => "closed"; - - await expect(client.broadcastBlock(forgedBlockWithTransactions)).toResolve(); - expect(spyEmit).not.toHaveBeenCalled(); - expect(logger.error).toHaveBeenCalledWith( - `Broadcast block failed: Request to ${mockHost.hostname}:${ - mockHost.port - } failed, because of 'Peer ${ - mockHost.hostname - } socket is not connected. State: ${mockHost.socket.getState()}'.`, + `Broadcast block failed: Request to ${host.hostname}:${host.port} failed, because of 'this.host.socket.request is not a function'.`, ); }); it("should broadcast valid blocks without error", async () => { - client.register([mockHost]); + client.register([host]); await expect(client.broadcastBlock(forgedBlockWithTransactions)).toResolve(); - expect(spyEmit).toHaveBeenCalledWith("p2p.peer.postBlock", expect.anything(), expect.anything()); + expect(nesClient.request).toHaveBeenCalledWith({ + path: "p2p.peer.postBlock", + headers: {}, + method: "POST", + payload: { block: expect.anything() } + }); expect(logger.error).not.toHaveBeenCalled(); }); - it("should not broadcast blocks on socketEmit error", async () => { - client.register([mockHost]); + it("should not broadcast blocks on socket.request error", async () => { + client.register([host]); - spyEmit.mockImplementation((__, data, cb) => cb("Error", data)); + nesClient.request.mockRejectedValueOnce(new Error("oops")); await expect(client.broadcastBlock(forgedBlockWithTransactions)).toResolve(); expect(logger.error).toHaveBeenCalledWith( - `Broadcast block failed: Request to ${mockHost.hostname}:${mockHost.port} failed, because of 'undefined'.`, + `Broadcast block failed: Request to ${host.hostname}:${host.port} failed, because of 'oops'.`, ); }); }); @@ -176,21 +118,21 @@ describe("Client", () => { beforeEach(() => { hosts = [ - mockHost, - mockHost, - mockHost, - mockHost, - mockHost, - mockHost, - mockHost, - mockHost, - mockHost, - mockHost, + host, + host, + host, + host, + host, + host, + host, + host, + host, + host, ]; }); it("should select the first open socket", async () => { - hosts[4].socket.getState = () => "open"; + hosts[4].socket._isReady = () => true; client.register(hosts); client.selectHost(); @@ -198,7 +140,7 @@ describe("Client", () => { }); it("should log debug message when no sockets are open", async () => { - hosts.forEach((host) => (host.socket.getState = () => "closed")); + hosts.forEach((host) => (host.socket._isReady = () => false)); client.register(hosts); await expect(client.selectHost()).rejects.toThrow( @@ -213,93 +155,85 @@ describe("Client", () => { }); describe("getTransactions", () => { - it("should broadcast get transactions internal event using socket emitter", async () => { - client.register([mockHost]); + it("should call p2p.internal.getUnconfirmedTransactions endpoint", async () => { + client.register([host]); await client.getTransactions(); - expect(spyEmit).toHaveBeenCalledWith( - "p2p.internal.getUnconfirmedTransactions", + expect(nesClient.request).toHaveBeenCalledWith( { - data: {}, - headers: { - "Content-Type": "application/json", - }, - }, - expect.anything(), + path: "p2p.internal.getUnconfirmedTransactions", + headers: {}, + method: "POST", + payload: {} + } ); }); }); describe("getRound", () => { it("should broadcast internal getRound transaction", async () => { - client.register([mockHost]); + client.register([host]); + host.socket._isReady = () => true; + await client.getRound(); - expect(spyEmit).toHaveBeenCalledWith( - "p2p.internal.getCurrentRound", + + expect(nesClient.request).toHaveBeenCalledWith( { - data: {}, - headers: { - "Content-Type": "application/json", - }, - }, - expect.anything(), + path: "p2p.internal.getCurrentRound", + headers: {}, + method: "POST", + payload: {} + } ); }); }); describe("syncWithNetwork", () => { it("should broadcast internal getRound transaction", async () => { - client.register([mockHost]); + client.register([host]); + host.socket._isReady = () => true; await client.syncWithNetwork(); - expect(spyEmit).toHaveBeenCalledWith( - "p2p.internal.syncBlockchain", + + expect(nesClient.request).toHaveBeenCalledWith( { - data: {}, - headers: { - "Content-Type": "application/json", - }, - }, - expect.anything(), + path: "p2p.internal.syncBlockchain", + headers: {}, + method: "POST", + payload: {} + } ); - expect(logger.debug).toHaveBeenCalledWith(`Sending wake-up check to relay node ${mockHost.hostname}`); + expect(logger.debug).toHaveBeenCalledWith(`Sending wake-up check to relay node ${host.hostname}`); }); it("should log error message if syncing fails", async () => { const errorMessage = "Fake Error"; - const emitSpy = jest.spyOn(client as any, "emit"); - emitSpy.mockImplementationOnce(() => { - throw new Error(errorMessage); - }); - client.register([mockHost]); + nesClient.request.mockRejectedValueOnce(new Error(errorMessage)) + host.socket._isReady = () => true; + client.register([host]); await expect(client.syncWithNetwork()).toResolve(); - expect(logger.error).toHaveBeenCalledWith(`Could not sync check: ${errorMessage}`); + expect(logger.error).toHaveBeenCalledWith(`Could not sync check: Request to 127.0.0.1:4000 failed, because of '${errorMessage}'.`); }); }); describe("getNetworkState", () => { it("should emit internal getNetworkState event", async () => { - client.register([mockHost]); + client.register([host]); await client.getNetworkState(); - expect(spyEmit).toHaveBeenCalledWith( - "p2p.internal.getNetworkState", + expect(nesClient.request).toHaveBeenCalledWith( { - data: {}, - headers: { - "Content-Type": "application/json", - }, - }, - expect.anything(), + path: "p2p.internal.getNetworkState", + headers: {}, + method: "POST", + payload: {} + } ); }); it("should return valid network state on error", async () => { const errorMessage = "Fake Error"; - const emitSpy = jest.spyOn(client as any, "emit"); - emitSpy.mockImplementationOnce(() => { - throw new Error(errorMessage); - }); + nesClient.request.mockRejectedValueOnce(new Error(errorMessage)) - client.register([mockHost]); + client.register([host]); const networkState = await client.getNetworkState(); expect(networkState.status).toEqual(NetworkStateStatus.Unknown); @@ -308,52 +242,49 @@ describe("Client", () => { describe("emitEvent", () => { it("should emit events from localhost", async () => { - mockHost.hostname = "127.0.0.1"; - client.register([mockHost]); + host.hostname = "127.0.0.1"; + client.register([host]); const data = { activeDelegates: ["delegate-one"] }; - client.emitEvent("test-event", data); - expect(spyEmit).toHaveBeenCalledWith( - "p2p.internal.emitEvent", + await client.emitEvent("test-event", data); + + expect(nesClient.request).toHaveBeenCalledWith( { - data: { + path: "p2p.internal.emitEvent", + headers: {}, + method: "POST", + payload: { body: data, event: "test-event", - }, - headers: { - "Content-Type": "application/json", - }, - }, - expect.anything(), + } + } ); }); it("should not emit events which are not from localhost", async () => { - mockHost.hostname = "127.0.0.2"; - client.register([mockHost]); + host.hostname = "127.0.0.2"; + client.register([host]); const data = { activeDelegates: ["delegate-one"] }; - client.emitEvent("test-event", data); + await client.emitEvent("test-event", data); expect(logger.error).toHaveBeenCalledWith("emitEvent: unable to find any local hosts."); }); it("should log error if emitting fails", async () => { const errorMessage = "Fake Error"; - const emitSpy = jest.spyOn(client as any, "emit"); - emitSpy.mockImplementationOnce(() => { - throw new Error(errorMessage); - }); - mockHost.hostname = "127.0.0.1"; - client.register([mockHost]); - const event = "test-event"; + nesClient.request.mockRejectedValueOnce(new Error(errorMessage)) + host.hostname = "127.0.0.1"; + client.register([host]); + + const event = "test-event"; const data = { activeDelegates: ["delegate-one"] }; - client.emitEvent(event, data); + await client.emitEvent(event, data); expect(logger.error).toHaveBeenCalledWith( - `Failed to emit "${event}" to "${mockHost.hostname}:${mockHost.port}"`, + `Failed to emit "${event}" to "${host.hostname}:${host.port}"`, ); }); }); diff --git a/__tests__/unit/core-forger/forger-service.test.ts b/__tests__/unit/core-forger/forger-service.test.ts index 176578673a..a35c26b83d 100644 --- a/__tests__/unit/core-forger/forger-service.test.ts +++ b/__tests__/unit/core-forger/forger-service.test.ts @@ -1,22 +1,19 @@ import "jest-extended"; -import { Client } from "@packages/core-forger/src/client"; import { HostNoResponseError, RelayCommunicationError } from "@packages/core-forger/src/errors"; import { ForgerService } from "@packages/core-forger/src/forger-service"; -import { BIP39 } from "@packages/core-forger/src/methods/bip39"; import { Container, Enums, Services, Utils } from "@packages/core-kernel"; import { NetworkStateStatus } from "@packages/core-p2p"; import { Crypto, Managers } from "@packages/crypto"; -import { Block } from "@packages/crypto/dist/blocks"; import { Address } from "@packages/crypto/src/identities"; import { BuilderFactory } from "@packages/crypto/src/transactions"; -import socketCluster from "socketcluster-client"; import { calculateActiveDelegates } from "./__utils__/calculate-active-delegates"; import { Sandbox } from "@arkecosystem/core-test-framework"; import { GetActiveDelegatesAction } from "@packages/core-database/src/actions"; import { ForgeNewBlockAction, IsForgingAllowedAction } from "@arkecosystem/core-forger/src/actions"; -jest.mock("socketcluster-client"); +import { Contracts } from "@arkecosystem/core-kernel"; +import { Interfaces } from "@arkecosystem/crypto"; let sandbox: Sandbox; const logger = { @@ -25,24 +22,16 @@ const logger = { info: jest.fn(), warning: jest.fn(), }; - -const initializeClient = (client: Client) => { - const mockHost = { - socket: { - on: () => {}, - disconnect: () => {}, - emit: () => {}, - getState: () => "open", - OPEN: "open", - }, - port: 4000, - hostname: "mock-1", - }; - // @ts-ignore - jest.spyOn(socketCluster, "create").mockImplementation(() => mockHost.socket); - // @ts-ignore - client.register([mockHost]); - return mockHost; +const client = { + register: jest.fn(), + dispose: jest.fn(), + broadcastBlock: jest.fn(), + syncWithNetwork: jest.fn(), + getRound: jest.fn(), + getNetworkState: jest.fn(), + getTransactions: jest.fn(), + emitEvent: jest.fn(), + selectHost: jest.fn(), }; beforeEach(() => { @@ -65,14 +54,12 @@ beforeEach(() => { }); afterEach(() => { - jest.restoreAllMocks(); jest.resetAllMocks(); }); describe("ForgerService", () => { let forgerService: ForgerService; - let client: Client; - let mockHost; + let mockHost = { hostname: "127.0.0.1", port: 4000 }; let delegates; let mockNetworkState; let mockTransaction; @@ -82,17 +69,16 @@ describe("ForgerService", () => { beforeEach(() => { forgerService = sandbox.app.resolve(ForgerService); - client = sandbox.app.resolve(Client); - mockHost = initializeClient(client); + + jest.spyOn(sandbox.app, "resolve").mockReturnValueOnce(client); // forger-service only resolves Client + const slotSpy = jest.spyOn(Crypto.Slots, "getTimeInMsUntilNextSlot"); slotSpy.mockReturnValue(0); delegates = calculateActiveDelegates(); - const corep2p = jest.requireActual("@packages/core-p2p"); round = { data: { delegates, timestamp: 50, reward: 0 }, canForge: false }; - corep2p.socketEmit = jest.fn().mockResolvedValue(round); mockNetworkState = { status: NetworkStateStatus.Default, getOverHeightBlockHeaders: () => [], @@ -125,7 +111,9 @@ describe("ForgerService", () => { describe("Register", () => { it("should register an associated client", async () => { forgerService.register({ hosts: [mockHost] }); - expect((forgerService as any).client.hosts).toEqual([mockHost]); + + expect(client.register).toBeCalledTimes(1); + expect(client.register).toBeCalledWith([mockHost]); }); }); @@ -145,6 +133,8 @@ describe("ForgerService", () => { describe("Boot", () => { it("should set delegates and log active delegates info message", async () => { forgerService.register({ hosts: [mockHost] }); + client.getRound.mockReturnValueOnce({ delegates }); + await expect(forgerService.boot(delegates)).toResolve(); expect((forgerService as any).delegates).toEqual(delegates); @@ -160,13 +150,17 @@ describe("ForgerService", () => { it("should skip logging when the service is already initialised", async () => { forgerService.register({ hosts: [mockHost] }); + client.getRound.mockReturnValueOnce({ delegates }); (forgerService as any).initialized = true; + await expect(forgerService.boot(delegates)).toResolve(); expect(logger.info).not.toHaveBeenCalledWith(`Forger Manager started.`); }); it("should not log when there are no active delegates", async () => { forgerService.register({ hosts: [mockHost] }); + client.getRound.mockReturnValueOnce({ delegates }); + await expect(forgerService.boot([])).toResolve(); expect(logger.info).toHaveBeenCalledTimes(1); expect(logger.info).toHaveBeenCalledWith(`Forger Manager started.`); @@ -175,10 +169,9 @@ describe("ForgerService", () => { it("should log inactive delegates correctly", async () => { const numberActive = 10; - const corep2p = jest.requireActual("@packages/core-p2p"); const round = { data: { delegates: delegates.slice(0, numberActive) } }; - corep2p.socketEmit = jest.fn().mockResolvedValue(round); + client.getRound.mockResolvedValueOnce(round.data as Contracts.P2P.CurrentRound); const expectedInactiveDelegatesMessage = `Loaded ${Utils.pluralize( "inactive delegate", @@ -196,9 +189,7 @@ describe("ForgerService", () => { }); it("should catch and log errors", async () => { - const corep2p = jest.requireActual("@packages/core-p2p"); - - corep2p.socketEmit = jest.fn().mockRejectedValue({}); + client.getRound.mockRejectedValueOnce(new Error("oops")); forgerService.register({ hosts: [mockHost] }); await expect(forgerService.boot(delegates)).toResolve(); @@ -212,11 +203,14 @@ describe("ForgerService", () => { slotSpy.mockReturnValue(timeout); jest.useFakeTimers(); + client.getRound.mockReturnValueOnce({ delegates }); forgerService.register({ hosts: [mockHost] }); await expect(forgerService.boot(delegates)).toResolve(); expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), timeout); + + jest.useRealTimers(); }); }); @@ -288,6 +282,7 @@ describe("ForgerService", () => { }); it("should log double forge warning for any overheight block headers", async () => { + client.getRound.mockReturnValueOnce({ delegates }); forgerService.register({ hosts: [mockHost] }); await forgerService.boot(delegates); @@ -320,6 +315,7 @@ describe("ForgerService", () => { }); it("should not allow forging if quorum is not met", async () => { + client.getRound.mockReturnValueOnce({ delegates }); forgerService.register({ hosts: [mockHost] }); await forgerService.boot(delegates); @@ -337,6 +333,7 @@ describe("ForgerService", () => { }); it("should allow forging if quorum is met", async () => { + client.getRound.mockReturnValueOnce({ delegates }); forgerService.register({ hosts: [mockHost] }); await forgerService.boot(delegates); @@ -351,6 +348,7 @@ describe("ForgerService", () => { }); it("should allow forging if quorum is met, not log warning if overheight delegate is not the same", async () => { + client.getRound.mockReturnValueOnce({ delegates }); forgerService.register({ hosts: [mockHost] }); await forgerService.boot(delegates); @@ -376,157 +374,6 @@ describe("ForgerService", () => { }); }); - describe("ForgeNewBlock", () => { - it("should fail to forge when delegate is already in next slot", async () => { - forgerService.register({ hosts: [mockHost] }); - - // @ts-ignore - const spyGetTransactions = jest.spyOn(forgerService.client, "getTransactions"); - // @ts-ignore - spyGetTransactions.mockResolvedValue(mockTransaction); - - await forgerService.boot(delegates); - - const address = `Delegate-Wallet-${2}`; - - const nextDelegateToForge: BIP39 = new BIP39(address); - - // @ts-ignore - await expect(forgerService.forgeNewBlock(nextDelegateToForge, mockRound, mockNetworkState)).toResolve(); - - const prettyName = `Username: ${address} (${nextDelegateToForge.publicKey})`; - - const failedForgeMessage = `Failed to forge new block by delegate ${prettyName}, because already in next slot.`; - - expect(logger.warning).toHaveBeenCalledWith(failedForgeMessage); - }); - - it("should fail to forge when there is not enough time left in slot", async () => { - const timeLeftInMs = 1000; - const spyTimeTillNextSlot = jest.spyOn(Crypto.Slots, "getTimeInMsUntilNextSlot"); - spyTimeTillNextSlot.mockReturnValue(timeLeftInMs); - - forgerService.register({ hosts: [mockHost] }); - - // @ts-ignore - const spyGetTransactions = jest.spyOn(forgerService.client, "getTransactions"); - // @ts-ignore - spyGetTransactions.mockResolvedValue(mockTransaction); - - await forgerService.boot(delegates); - - const address = `Delegate-Wallet-${2}`; - - const nextDelegateToForge: BIP39 = new BIP39(address); - - const spyNextSlot = jest.spyOn(Crypto.Slots, "getSlotNumber"); - spyNextSlot.mockReturnValue(0); - - // @ts-ignore - await expect(forgerService.forgeNewBlock(nextDelegateToForge, mockRound, mockNetworkState)).toResolve(); - - const prettyName = `Username: ${address} (${nextDelegateToForge.publicKey})`; - - const minimumMs = 2000; - - const failedForgeMessage = `Failed to forge new block by delegate ${prettyName}, because there were ${timeLeftInMs}ms left in the current slot (less than ${minimumMs}ms).`; - - expect(logger.warning).toHaveBeenCalledWith(failedForgeMessage); - }); - - it("should forge valid new blocks", async () => { - const timeLeftInMs = 3000; - const spyTimeTillNextSlot = jest.spyOn(Crypto.Slots, "getTimeInMsUntilNextSlot"); - spyTimeTillNextSlot.mockReturnValue(timeLeftInMs); - - forgerService.register({ hosts: [mockHost] }); - - // @ts-ignore - const spyGetTransactions = jest.spyOn(forgerService.client, "getTransactions"); - // @ts-ignore - spyGetTransactions.mockResolvedValue(mockTransaction); - - await forgerService.boot(delegates); - - const address = `Delegate-Wallet-${2}`; - - const nextDelegateToForge: BIP39 = new BIP39(address); - - const spyNextSlot = jest.spyOn(Crypto.Slots, "getSlotNumber"); - spyNextSlot.mockReturnValue(0); - - // @ts-ignore - const spyClientBroadcastBlock = jest.spyOn(forgerService.client, "broadcastBlock"); - // @ts-ignore - const spyClientEmitEvent = jest.spyOn(forgerService.client, "emitEvent"); - - // @ts-ignore - await expect(forgerService.forgeNewBlock(nextDelegateToForge, mockRound, mockNetworkState)).toResolve(); - - const prettyName = `Username: ${address} (${nextDelegateToForge.publicKey})`; - - const infoForgeMessageOne = `Forged new block`; - const infoForgeMessageTwo = ` by delegate ${prettyName}`; - - expect(logger.info).toHaveBeenCalledWith(expect.stringContaining(infoForgeMessageOne)); - expect(logger.info).toHaveBeenCalledWith(expect.stringContaining(infoForgeMessageTwo)); - - expect(spyClientBroadcastBlock).toHaveBeenCalledWith(expect.any(Block)); - - expect(spyClientEmitEvent).toHaveBeenNthCalledWith(1, Enums.BlockEvent.Forged, expect.anything()); - - expect(spyClientEmitEvent).toHaveBeenNthCalledWith(2, Enums.TransactionEvent.Forged, transaction.data); - }); - - it("should forge valid new blocks when passed specific milestones", async () => { - const spyMilestone = jest.spyOn(Managers.configManager, "getMilestone"); - spyMilestone.mockReturnValue({ block: { idFullSha256: true, version: 0 }, reward: 0 }); - - const timeLeftInMs = 3000; - const spyTimeTillNextSlot = jest.spyOn(Crypto.Slots, "getTimeInMsUntilNextSlot"); - spyTimeTillNextSlot.mockReturnValue(timeLeftInMs); - - forgerService.register({ hosts: [mockHost] }); - - // @ts-ignore - const spyGetTransactions = jest.spyOn(forgerService.client, "getTransactions"); - // @ts-ignore - spyGetTransactions.mockResolvedValue(mockTransaction); - - mockNetworkState.lastBlockId = "c2fa2d400b4c823873d476f6e0c9e423cf925e9b48f1b5706c7e2771d4095538"; - - await forgerService.boot(delegates); - - const address = `Delegate-Wallet-${2}`; - - const nextDelegateToForge: BIP39 = new BIP39(address); - - const spyNextSlot = jest.spyOn(Crypto.Slots, "getSlotNumber"); - spyNextSlot.mockReturnValue(0); - - // @ts-ignore - const spyClientBroadcastBlock = jest.spyOn(forgerService.client, "broadcastBlock"); - // @ts-ignore - const spyClientEmitEvent = jest.spyOn(forgerService.client, "emitEvent"); - - // @ts-ignore - await expect(forgerService.forgeNewBlock(nextDelegateToForge, round.data, mockNetworkState)).toResolve(); - - const prettyName = `Username: ${address} (${nextDelegateToForge.publicKey})`; - - const infoForgeMessageOne = `Forged new block`; - const infoForgeMessageTwo = ` by delegate ${prettyName}`; - - expect(logger.info).toHaveBeenCalledWith(expect.stringContaining(infoForgeMessageOne)); - expect(logger.info).toHaveBeenCalledWith(expect.stringContaining(infoForgeMessageTwo)); - - expect(spyClientBroadcastBlock).toHaveBeenCalledWith(expect.any(Block)); - - expect(spyClientEmitEvent).toHaveBeenNthCalledWith(1, Enums.BlockEvent.Forged, expect.anything()); - - expect(spyClientEmitEvent).toHaveBeenNthCalledWith(2, Enums.TransactionEvent.Forged, transaction.data); - }); - }); describe("checkSlot", () => { it("should do nothing when the forging service is stopped", async () => { @@ -546,11 +393,13 @@ describe("ForgerService", () => { forgerService.register({ hosts: [mockHost] }); (forgerService as any).initialized = true; + client.getRound.mockReturnValueOnce({ delegates }); await expect(forgerService.boot(delegates)).toResolve(); expect(logger.info).not.toHaveBeenCalledWith(`Forger Manager started.`); jest.useFakeTimers(); + client.getRound.mockReturnValueOnce({ delegates }); await expect(forgerService.checkSlot()).toResolve(); expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 200); @@ -558,14 +407,14 @@ describe("ForgerService", () => { expect(logger.warning).not.toHaveBeenCalled(); expect(logger.error).not.toHaveBeenCalled(); expect(logger.debug).not.toHaveBeenCalled(); + + jest.useRealTimers(); }); it("should set timer and log nextForger which is active on node", async () => { const slotSpy = jest.spyOn(Crypto.Slots, "getTimeInMsUntilNextSlot"); slotSpy.mockReturnValue(0); - const corep2p = jest.requireActual("@packages/core-p2p"); - const round = { data: { delegates, @@ -577,7 +426,7 @@ describe("ForgerService", () => { }, }; - corep2p.socketEmit = jest.fn().mockResolvedValue(round); + client.getRound.mockResolvedValueOnce(round.data as Contracts.P2P.CurrentRound); forgerService.register({ hosts: [mockHost] }); (forgerService as any).initialized = true; @@ -589,6 +438,7 @@ describe("ForgerService", () => { // @ts-ignore const spyClientSyncWithNetwork = jest.spyOn(forgerService.client, "syncWithNetwork"); + client.getRound.mockResolvedValueOnce(round.data as Contracts.P2P.CurrentRound); await expect(forgerService.checkSlot()).toResolve(); expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 0); @@ -601,14 +451,14 @@ describe("ForgerService", () => { expect(logger.info).toHaveBeenCalledWith(expectedInfoMessage); expect(logger.warning).not.toHaveBeenCalled(); expect(logger.error).not.toHaveBeenCalled(); - expect(logger.debug).toHaveBeenCalledWith(`Sending wake-up check to relay node ${mockHost.hostname}`); + + jest.useRealTimers(); }); it("should set timer and not log message if nextForger is not active", async () => { const slotSpy = jest.spyOn(Crypto.Slots, "getTimeInMsUntilNextSlot"); slotSpy.mockReturnValue(0); - const corep2p = jest.requireActual("@packages/core-p2p"); const round = { data: { delegates, @@ -620,7 +470,7 @@ describe("ForgerService", () => { }, }; - corep2p.socketEmit = jest.fn().mockResolvedValue(round); + client.getRound.mockResolvedValueOnce(round.data as Contracts.P2P.CurrentRound); forgerService.register({ hosts: [mockHost] }); (forgerService as any).initialized = true; @@ -631,7 +481,9 @@ describe("ForgerService", () => { jest.useFakeTimers(); // @ts-ignore const spyClientSyncWithNetwork = jest.spyOn(forgerService.client, "syncWithNetwork"); + spyClientSyncWithNetwork.mockReset(); + client.getRound.mockResolvedValueOnce(round.data as Contracts.P2P.CurrentRound); await expect(forgerService.checkSlot()).toResolve(); expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 0); @@ -645,18 +497,21 @@ describe("ForgerService", () => { expect(logger.warning).not.toHaveBeenCalled(); expect(logger.error).not.toHaveBeenCalled(); expect(logger.debug).not.toHaveBeenCalledWith(`Sending wake-up check to relay node ${mockHost.hostname}`); + + jest.useRealTimers(); }); it("should forge valid blocks when forging is allowed", async () => { const slotSpy = jest.spyOn(Crypto.Slots, "getTimeInMsUntilNextSlot"); slotSpy.mockReturnValue(0); - const address = `Delegate-Wallet-${delegates.length - 2}`; - - const nextDelegateToForge: BIP39 = new BIP39(address); + const mockBlock = { data: {} } as Interfaces.IBlock; + const nextDelegateToForge = { + publicKey: delegates[2].publicKey, + forge: jest.fn().mockReturnValue(mockBlock) + } delegates[delegates.length - 2] = Object.assign(nextDelegateToForge, delegates[delegates.length - 2]); - const corep2p = jest.requireActual("@packages/core-p2p"); const round = { data: { delegates, @@ -669,11 +524,12 @@ describe("ForgerService", () => { height: 3, }, timestamp: 0, - reward: 0, + reward: "0", + current: 1 }, }; - corep2p.socketEmit = jest.fn().mockResolvedValue(round); + client.getRound.mockResolvedValueOnce(round.data as Contracts.P2P.CurrentRound); forgerService.register({ hosts: [mockHost] }); @@ -691,6 +547,7 @@ describe("ForgerService", () => { await expect(forgerService.boot(delegates)).toResolve(); + client.getRound.mockResolvedValueOnce(round.data as Contracts.P2P.CurrentRound); jest.useFakeTimers(); // @ts-ignore await expect(forgerService.checkSlot()).toResolve(); @@ -705,18 +562,21 @@ describe("ForgerService", () => { const loggerWarningMessage = `The NetworkState height (${mockNetworkState.nodeHeight}) and round height (${round.data.lastBlock.height}) are out of sync. This indicates delayed blocks on the network.`; expect(logger.warning).toHaveBeenCalledWith(loggerWarningMessage); + + jest.useRealTimers(); }); it("should not log warning message when nodeHeight does not equal last block height", async () => { const slotSpy = jest.spyOn(Crypto.Slots, "getTimeInMsUntilNextSlot"); slotSpy.mockReturnValue(0); - const address = `Delegate-Wallet-${delegates.length - 2}`; - - const nextDelegateToForge: BIP39 = new BIP39(address); + const mockBlock = { data: {} } as Interfaces.IBlock; + const nextDelegateToForge = { + publicKey: delegates[2].publicKey, + forge: jest.fn().mockReturnValue(mockBlock) + } delegates[delegates.length - 2] = Object.assign(nextDelegateToForge, delegates[delegates.length - 2]); - const corep2p = jest.requireActual("@packages/core-p2p"); const round = { data: { delegates, @@ -729,11 +589,11 @@ describe("ForgerService", () => { height: 10, }, timestamp: 0, - reward: 0, + reward: "0", }, }; - corep2p.socketEmit = jest.fn().mockResolvedValue(round); + client.getRound.mockResolvedValueOnce(round.data as Contracts.P2P.CurrentRound); forgerService.register({ hosts: [mockHost] }); @@ -751,9 +611,10 @@ describe("ForgerService", () => { await expect(forgerService.boot(delegates)).toResolve(); + client.getRound.mockResolvedValueOnce(round.data as Contracts.P2P.CurrentRound); jest.useFakeTimers(); // @ts-ignore - await expect(forgerService.checkSlot()).toResolve(); + await forgerService.checkSlot(); expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 0); @@ -765,18 +626,21 @@ describe("ForgerService", () => { const loggerWarningMessage = `The NetworkState height (${mockNetworkState.nodeHeight}) and round height (${round.data.lastBlock.height}) are out of sync. This indicates delayed blocks on the network.`; expect(logger.warning).not.toHaveBeenCalledWith(loggerWarningMessage); + + jest.useRealTimers(); }); it("should not allow forging when blocked by network status", async () => { const slotSpy = jest.spyOn(Crypto.Slots, "getTimeInMsUntilNextSlot"); slotSpy.mockReturnValue(0); - const address = `Delegate-Wallet-${delegates.length - 2}`; - - const nextDelegateToForge: BIP39 = new BIP39(address); + const mockBlock = { data: {} } as Interfaces.IBlock; + const nextDelegateToForge = { + publicKey: delegates[2].publicKey, + forge: jest.fn().mockReturnValue(mockBlock) + } delegates[delegates.length - 2] = Object.assign(nextDelegateToForge, delegates[delegates.length - 2]); - const corep2p = jest.requireActual("@packages/core-p2p"); const round = { data: { delegates, @@ -789,11 +653,11 @@ describe("ForgerService", () => { height: 10, }, timestamp: 0, - reward: 0, + reward: "0", }, }; - corep2p.socketEmit = jest.fn().mockResolvedValue(round); + client.getRound.mockResolvedValueOnce(round.data as Contracts.P2P.CurrentRound); forgerService.register({ hosts: [mockHost] }); @@ -813,6 +677,7 @@ describe("ForgerService", () => { await expect(forgerService.boot(delegates)).toResolve(); + client.getRound.mockResolvedValueOnce(round.data as Contracts.P2P.CurrentRound); // @ts-ignore await expect(forgerService.checkSlot()).toResolve(); @@ -823,12 +688,13 @@ describe("ForgerService", () => { const slotSpy = jest.spyOn(Crypto.Slots, "getTimeInMsUntilNextSlot"); slotSpy.mockReturnValue(0); - const address = `Delegate-Wallet-${delegates.length - 2}`; - - const nextDelegateToForge: BIP39 = new BIP39(address); + const mockBlock = { data: {} } as Interfaces.IBlock; + const nextDelegateToForge = { + publicKey: delegates[2].publicKey, + forge: jest.fn().mockReturnValue(mockBlock) + } delegates[delegates.length - 2] = Object.assign(nextDelegateToForge, delegates[delegates.length - 2]); - const corep2p = jest.requireActual("@packages/core-p2p"); const round = { data: { delegates, @@ -841,11 +707,11 @@ describe("ForgerService", () => { height: 10, }, timestamp: 0, - reward: 0, + reward: "0", }, }; - corep2p.socketEmit = jest.fn().mockResolvedValue(round); + client.getRound.mockResolvedValueOnce(round.data as Contracts.P2P.CurrentRound); forgerService.register({ hosts: [mockHost] }); @@ -865,6 +731,7 @@ describe("ForgerService", () => { await expect(forgerService.boot(delegates)).toResolve(); + client.getRound.mockResolvedValueOnce(round.data as Contracts.P2P.CurrentRound); jest.useFakeTimers(); // @ts-ignore await expect(forgerService.checkSlot()).toResolve(); @@ -874,18 +741,21 @@ describe("ForgerService", () => { expect(spyForgeNewBlock).not.toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith(`Waiting for relay to become ready.`); + + jest.useRealTimers(); }); it("should log warning when error isn't a network error", async () => { const slotSpy = jest.spyOn(Crypto.Slots, "getTimeInMsUntilNextSlot"); slotSpy.mockReturnValue(0); - const address = `Delegate-Wallet-${delegates.length - 2}`; - - const nextDelegateToForge: BIP39 = new BIP39(address); + const mockBlock = { data: {} } as Interfaces.IBlock; + const nextDelegateToForge = { + publicKey: delegates[2].publicKey, + forge: jest.fn().mockReturnValue(mockBlock) + } delegates[delegates.length - 2] = Object.assign(nextDelegateToForge, delegates[delegates.length - 2]); - const corep2p = jest.requireActual("@packages/core-p2p"); const round = { data: { delegates, @@ -898,11 +768,11 @@ describe("ForgerService", () => { height: 10, }, timestamp: 0, - reward: 0, + reward: "0", }, }; - corep2p.socketEmit = jest.fn().mockResolvedValue(round); + client.getRound.mockResolvedValueOnce(round.data as Contracts.P2P.CurrentRound); forgerService.register({ hosts: [mockHost] }); @@ -924,6 +794,7 @@ describe("ForgerService", () => { await expect(forgerService.boot(delegates)).toResolve(); + client.getRound.mockResolvedValueOnce(round.data as Contracts.P2P.CurrentRound); jest.useFakeTimers(); // @ts-ignore await expect(forgerService.checkSlot()).toResolve(); @@ -935,18 +806,21 @@ describe("ForgerService", () => { expect(logger.warning).toHaveBeenCalledWith( `Request to ${mockEndpoint} failed, because of '${mockError}'.`, ); + + jest.useRealTimers(); }); it("should log error when error thrown during attempted forge isn't a network error", async () => { const slotSpy = jest.spyOn(Crypto.Slots, "getTimeInMsUntilNextSlot"); slotSpy.mockReturnValue(0); - const address = `Delegate-Wallet-${delegates.length - 2}`; - - const nextDelegateToForge: BIP39 = new BIP39(address); + const mockBlock = { data: {} } as Interfaces.IBlock; + const nextDelegateToForge = { + publicKey: delegates[2].publicKey, + forge: jest.fn().mockReturnValue(mockBlock) + } delegates[delegates.length - 2] = Object.assign(nextDelegateToForge, delegates[delegates.length - 2]); - const corep2p = jest.requireActual("@packages/core-p2p"); const round = { data: { delegates, @@ -959,12 +833,12 @@ describe("ForgerService", () => { height: 10, }, timestamp: 0, - reward: 0, + reward: "0", current: 9, }, }; - corep2p.socketEmit = jest.fn().mockResolvedValue(round); + client.getRound.mockResolvedValueOnce(round.data as Contracts.P2P.CurrentRound); forgerService.register({ hosts: [mockHost] }); @@ -988,6 +862,7 @@ describe("ForgerService", () => { await expect(forgerService.boot(delegates)).toResolve(); + client.getRound.mockResolvedValueOnce(round.data as Contracts.P2P.CurrentRound); jest.useFakeTimers(); // @ts-ignore await expect(forgerService.checkSlot()).toResolve(); @@ -1001,21 +876,24 @@ describe("ForgerService", () => { expect(spyClientEmitEvent).toHaveBeenCalledWith(Enums.ForgerEvent.Failed, { error: mockError }); const infoMessage = `Round: ${round.data.current.toLocaleString()}, height: ${round.data.lastBlock.height.toLocaleString()}`; expect(logger.info).toHaveBeenCalledWith(infoMessage); + + jest.useRealTimers(); }); it("should not error when there is no round info", async () => { const slotSpy = jest.spyOn(Crypto.Slots, "getTimeInMsUntilNextSlot"); slotSpy.mockReturnValue(0); - const address = `Delegate-Wallet-${delegates.length - 2}`; - - const nextDelegateToForge: BIP39 = new BIP39(address); + const mockBlock = { data: {} } as Interfaces.IBlock; + const nextDelegateToForge = { + publicKey: delegates[2].publicKey, + forge: jest.fn().mockReturnValue(mockBlock) + } delegates[delegates.length - 2] = Object.assign(nextDelegateToForge, delegates[delegates.length - 2]); - const corep2p = jest.requireActual("@packages/core-p2p"); - const round = {}; + const round = undefined; - corep2p.socketEmit = jest.fn().mockResolvedValue(round); + client.getRound.mockResolvedValueOnce(round as Contracts.P2P.CurrentRound); forgerService.register({ hosts: [mockHost] }); @@ -1031,6 +909,7 @@ describe("ForgerService", () => { await expect(forgerService.boot(delegates)).toResolve(); + client.getRound.mockResolvedValueOnce(round as Contracts.P2P.CurrentRound); jest.useFakeTimers(); // @ts-ignore await expect(forgerService.checkSlot()).toResolve(); @@ -1043,6 +922,163 @@ describe("ForgerService", () => { expect(spyClientEmitEvent).toHaveBeenCalledWith(Enums.ForgerEvent.Failed, { error: expect.any(String) }); expect(logger.info).not.toHaveBeenCalled(); + + jest.useRealTimers(); }); }); + + describe("ForgeNewBlock", () => { + it("should fail to forge when delegate is already in next slot", async () => { + client.getRound.mockReturnValueOnce({ delegates }); + forgerService.register({ hosts: [mockHost] }); + + client.getTransactions.mockResolvedValueOnce(mockTransaction); + + await forgerService.boot(delegates); + + const address = `Delegate-Wallet-${2}`; + + const mockBlock = { data: {} } as Interfaces.IBlock; + const nextDelegateToForge = { + publicKey: delegates[2].publicKey, + forge: jest.fn().mockReturnValue(mockBlock) + } + + // @ts-ignore + await expect(forgerService.forgeNewBlock(nextDelegateToForge, mockRound, mockNetworkState)).toResolve(); + + const prettyName = `Username: ${address} (${nextDelegateToForge.publicKey})`; + + const failedForgeMessage = `Failed to forge new block by delegate ${prettyName}, because already in next slot.`; + + expect(logger.warning).toHaveBeenCalledWith(failedForgeMessage); + }); + + it("should fail to forge when there is not enough time left in slot", async () => { + client.getRound.mockReturnValueOnce({ delegates }); + const timeLeftInMs = 1000; + const spyTimeTillNextSlot = jest.spyOn(Crypto.Slots, "getTimeInMsUntilNextSlot"); + spyTimeTillNextSlot.mockReturnValue(timeLeftInMs); + + forgerService.register({ hosts: [mockHost] }); + + client.getTransactions.mockResolvedValueOnce(mockTransaction); + + await forgerService.boot(delegates); + + const address = `Delegate-Wallet-${2}`; + + const mockBlock = { data: {} } as Interfaces.IBlock; + const nextDelegateToForge = { + publicKey: delegates[2].publicKey, + forge: jest.fn().mockReturnValue(mockBlock) + } + + const spyNextSlot = jest.spyOn(Crypto.Slots, "getSlotNumber"); + spyNextSlot.mockReturnValue(0); + + // @ts-ignore + await expect(forgerService.forgeNewBlock(nextDelegateToForge, mockRound, mockNetworkState)).toResolve(); + + const prettyName = `Username: ${address} (${nextDelegateToForge.publicKey})`; + + const minimumMs = 2000; + + const failedForgeMessage = `Failed to forge new block by delegate ${prettyName}, because there were ${timeLeftInMs}ms left in the current slot (less than ${minimumMs}ms).`; + + expect(logger.warning).toHaveBeenCalledWith(failedForgeMessage); + }); + + it("should forge valid new blocks", async () => { + client.getRound.mockReturnValueOnce({ delegates }); + const timeLeftInMs = 3000; + const spyTimeTillNextSlot = jest.spyOn(Crypto.Slots, "getTimeInMsUntilNextSlot"); + spyTimeTillNextSlot.mockReturnValue(timeLeftInMs); + + forgerService.register({ hosts: [mockHost] }); + + client.getTransactions.mockResolvedValueOnce(mockTransaction); + + await forgerService.boot(delegates); + + const address = `Delegate-Wallet-${2}`; + + const mockBlock = { data: {} } as Interfaces.IBlock; + const nextDelegateToForge = { + publicKey: delegates[2].publicKey, + forge: jest.fn().mockReturnValue(mockBlock) + } + + const spyNextSlot = jest.spyOn(Crypto.Slots, "getSlotNumber"); + spyNextSlot.mockReturnValue(0); + + client.emitEvent.mockReset(); + // @ts-ignore + await expect(forgerService.forgeNewBlock(nextDelegateToForge, mockRound, mockNetworkState)).toResolve(); + + const prettyName = `Username: ${address} (${nextDelegateToForge.publicKey})`; + + const infoForgeMessageOne = `Forged new block`; + const infoForgeMessageTwo = ` by delegate ${prettyName}`; + + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining(infoForgeMessageOne)); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining(infoForgeMessageTwo)); + + expect(client.broadcastBlock).toHaveBeenCalledWith(mockBlock); + + expect(client.emitEvent).toHaveBeenNthCalledWith(1, Enums.BlockEvent.Forged, expect.anything()); + + expect(client.emitEvent).toHaveBeenNthCalledWith(2, Enums.TransactionEvent.Forged, transaction.data); + }); + + it("should forge valid new blocks when passed specific milestones", async () => { + client.getRound.mockReturnValueOnce({ delegates }); + const spyMilestone = jest.spyOn(Managers.configManager, "getMilestone"); + spyMilestone.mockReturnValueOnce({ block: { idFullSha256: true, version: 0 }, reward: 0 }); + + const timeLeftInMs = 3000; + const spyTimeTillNextSlot = jest.spyOn(Crypto.Slots, "getTimeInMsUntilNextSlot"); + spyTimeTillNextSlot.mockReturnValueOnce(timeLeftInMs).mockReturnValueOnce(timeLeftInMs); + + forgerService.register({ hosts: [mockHost] }); + + client.getTransactions.mockResolvedValueOnce(mockTransaction); + + mockNetworkState.lastBlockId = "c2fa2d400b4c823873d476f6e0c9e423cf925e9b48f1b5706c7e2771d4095538"; + + jest.useFakeTimers(); + await forgerService.boot(delegates); + + const address = `Delegate-Wallet-${2}`; + + const mockBlock = { data: {} } as Interfaces.IBlock; + const nextDelegateToForge = { + publicKey: delegates[2].publicKey, + forge: jest.fn().mockReturnValue(mockBlock) + } + + const spyNextSlot = jest.spyOn(Crypto.Slots, "getSlotNumber"); + spyNextSlot.mockReturnValueOnce(0).mockReturnValueOnce(0); + + client.emitEvent.mockReset(); + // @ts-ignore + await forgerService.forgeNewBlock(nextDelegateToForge, round.data, mockNetworkState); + + const prettyName = `Username: ${address} (${nextDelegateToForge.publicKey})`; + + const infoForgeMessageOne = `Forged new block`; + const infoForgeMessageTwo = ` by delegate ${prettyName}`; + + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining(infoForgeMessageOne)); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining(infoForgeMessageTwo)); + + expect(client.broadcastBlock).toHaveBeenCalledWith(mockBlock); + + expect(client.emitEvent).toHaveBeenNthCalledWith(1, Enums.BlockEvent.Forged, expect.anything()); + + expect(client.emitEvent).toHaveBeenNthCalledWith(2, Enums.TransactionEvent.Forged, transaction.data); + jest.useRealTimers(); + }); + }); + }); diff --git a/__tests__/unit/core-forger/mocks/nes.ts b/__tests__/unit/core-forger/mocks/nes.ts new file mode 100644 index 0000000000..ee80132e10 --- /dev/null +++ b/__tests__/unit/core-forger/mocks/nes.ts @@ -0,0 +1,11 @@ +export const nesClient = { + connect: jest.fn().mockReturnValue(new Promise(resolve => resolve())), + disconnect: jest.fn(), + request: jest.fn().mockReturnValue({ payload: {} }), + onError: jest.fn(), + _isReady: jest.fn().mockReturnValue(true) +}; + +export default { + Client: jest.fn().mockImplementation(() => nesClient) +}; \ No newline at end of file diff --git a/__tests__/unit/core-forger/service-provider.test.ts b/__tests__/unit/core-forger/service-provider.test.ts index cc2c6a1771..5a5554b78a 100644 --- a/__tests__/unit/core-forger/service-provider.test.ts +++ b/__tests__/unit/core-forger/service-provider.test.ts @@ -1,557 +1,136 @@ -import "jest-extended"; - -import { ServiceProvider } from "@packages/core-forger/src"; -import { Client } from "@packages/core-forger/src/client"; -import { DelegateTracker } from "@packages/core-forger/src/delegate-tracker"; -import { ForgerService } from "@packages/core-forger/src/forger-service"; -import { Application, Container, Contracts, Enums, Providers, Services } from "@packages/core-kernel"; -import { Wallet } from "@packages/core-state/src/wallets"; -import { Identities } from "@packages/crypto"; -import socketCluster from "socketcluster-client"; - -jest.mock("socketcluster-client"); - -afterAll(() => jest.clearAllMocks()); - -const initializeClient = (client: Client) => { - const mockHost = { - socket: { - on: () => {}, - disconnect: () => {}, - emit: () => {}, - getState: () => "open", - OPEN: "open", - }, - port: 4000, - hostname: "mock-1", - }; - const socketClusterSpy = jest.spyOn(socketCluster, "create"); - // @ts-ignore - socketClusterSpy.mockImplementation(() => mockHost.socket); - // @ts-ignore - client.register([mockHost]); - return mockHost; -}; - -const calculateActiveDelegates = (): Wallet[] => { - const activeDelegates = []; - for (let i = 0; i < 51; i++) { - const address = `Delegate-Wallet-${i}`; - const wallet = new Wallet(address, null); - wallet.publicKey = Identities.PublicKey.fromPassphrase(address); - - activeDelegates.push(wallet); - } - return activeDelegates; -}; +import { ServiceProvider } from "@arkecosystem/core-forger/src/service-provider"; +import { DelegateFactory } from "@arkecosystem/core-forger/src/delegate-factory"; +import { Container, Application, Providers } from "@arkecosystem/core-kernel"; describe("ServiceProvider", () => { - it("should fail to register when mock options are now set", async () => { - const app: Application = new Application(new Container.Container()); + let app: Application; + let serviceProvider: ServiceProvider; - app.bind(Container.Identifiers.PluginConfiguration).to(Providers.PluginConfiguration).inSingletonScope(); - app.bind(Container.Identifiers.TriggerService).to(Services.Triggers.Triggers).inSingletonScope(); - - const logger = { - error: jest.fn(), - debug: jest.fn(), - info: jest.fn(), - warning: jest.fn(), - }; - - app.bind(Container.Identifiers.LogService).toConstantValue(logger); - - const serviceProvider = app.resolve(ServiceProvider); - - const pluginConfiguration = app.get(Container.Identifiers.PluginConfiguration); + const triggerService = { bind: jest.fn() }; - app.resolve(ForgerService); - app.resolve(Client); - - // @ts-ignore - const instance: Providers.PluginConfiguration = pluginConfiguration.from("core-forger", {}); - - serviceProvider.setConfig(instance); - - await expect(serviceProvider.register()).toReject(); - }); + const bip39DelegateMock = { address: "D6Z26L69gdk8qYmTv5uzk3uGepigtHY4ax"} as any; + const bip38DelegateMock = { address: "D6Z26L69gbk8qYmTv5uzk3uGepigtHY4ax"} as any; - it("should register with the correct config", async () => { - const app: Application = new Application(new Container.Container()); + beforeEach(() => { + app = new Application(new Container.Container()); + app.bind(Container.Identifiers.LogService).toConstantValue({}); + app.bind(Container.Identifiers.EventDispatcherService).toConstantValue({ listen: jest.fn() }); + app.bind(Container.Identifiers.BlockchainService).toConstantValue({}); + app.bind(Container.Identifiers.WalletRepository).toConstantValue({}); + app.bind(Container.Identifiers.TriggerService).toConstantValue(triggerService); app.bind(Container.Identifiers.PluginConfiguration).to(Providers.PluginConfiguration).inSingletonScope(); - app.bind(Container.Identifiers.TriggerService).to(Services.Triggers.Triggers).inSingletonScope(); - - const logger = { - error: jest.fn(), - debug: jest.fn(), - info: jest.fn(), - warning: jest.fn(), - }; - - app.bind(Container.Identifiers.LogService).toConstantValue(logger); - - const serviceProvider = app.resolve(ServiceProvider); - - const pluginConfiguration = app.get(Container.Identifiers.PluginConfiguration); - - app.resolve(ForgerService); - const client = app.resolve(Client); - const mockHost = initializeClient(client); - - // @ts-ignore - const instance: Providers.PluginConfiguration = pluginConfiguration.from("core-forger", { hosts: [mockHost] }); - - serviceProvider.setConfig(instance); - - await expect(serviceProvider.register()).toResolve(); - }); - it("boot should set bip 39 delegates and start tracker", async () => { - const app: Application = new Application(new Container.Container()); + app.config("delegates", { secrets: [], bip38: "dummy bip 38" }); + app.config("app", { flags: { bip38: "dummy bip 38", password: "dummy pwd" } }); + + serviceProvider = app.resolve(ServiceProvider); - app.bind(Container.Identifiers.PluginConfiguration).to(Providers.PluginConfiguration).inSingletonScope(); - app.bind(Container.Identifiers.TriggerService).to(Services.Triggers.Triggers).inSingletonScope(); - - const logger = { - error: jest.fn(), - debug: jest.fn(), - info: jest.fn(), - warning: jest.fn(), - }; - - app.bind(Container.Identifiers.LogService).toConstantValue(logger); - - const serviceProvider = app.resolve(ServiceProvider); - - const pluginConfiguration = app.get(Container.Identifiers.PluginConfiguration); - - app.resolve(ForgerService); - const client = app.resolve(Client); - const mockHost = initializeClient(client); - - const instance: Providers.PluginConfiguration = pluginConfiguration.from("core-forger", { + const pluginConfiguration = app.resolve(Providers.PluginConfiguration); + pluginConfiguration.from("core-forger", { // @ts-ignore - hosts: [mockHost], - }); - - serviceProvider.setConfig(instance); - - app.config( - "delegates.secrets", - calculateActiveDelegates().map((delegate) => delegate.publicKey), - ); - - app.config("app.flags", { - bip38: false, - password: null, - }); - - await expect(serviceProvider.register()).toResolve(); - await expect(serviceProvider.boot()).toResolve(); - }); - - it("boot should set bip 38 delegates and start tracker", async () => { - const app: Application = new Application(new Container.Container()); - - app.bind(Container.Identifiers.PluginConfiguration).to(Providers.PluginConfiguration).inSingletonScope(); - app.bind(Container.Identifiers.TriggerService).to(Services.Triggers.Triggers).inSingletonScope(); - - const logger = { - error: jest.fn(), - debug: jest.fn(), - info: jest.fn(), - warning: jest.fn(), - }; - - app.bind(Container.Identifiers.LogService).toConstantValue(logger); - - const serviceProvider = app.resolve(ServiceProvider); - - const pluginConfiguration = app.get(Container.Identifiers.PluginConfiguration); - - app.resolve(ForgerService); - const client = app.resolve(Client); - const mockHost = initializeClient(client); - - const instance: Providers.PluginConfiguration = pluginConfiguration.from("core-forger", { - // @ts-ignore - hosts: [mockHost], - }); - - serviceProvider.setConfig(instance); - - app.config("delegates.secrets", []); - - const bip38: string = "6PYTQC4c2vBv6PGvV4HibNni6wNsHsGbR1qpL1DfkCNihsiWwXnjvJMU4B"; - - app.config("app.flags", { - bip38, - password: "bip38-password", - }); - - await expect(serviceProvider.register()).toResolve(); - await expect(serviceProvider.boot()).toResolve(); - }); - - it("boot should start tracker and emit event", async () => { - const app: Application = new Application(new Container.Container()); - - const mockLastBlock = { - data: { height: 3, timestamp: 111150 }, - }; - - @Container.injectable() - class MockDatabaseService { - public async getActiveDelegates(): Promise { - return []; - } - } - - @Container.injectable() - class MockWalletRepository { - public findByPublicKey(publicKey: string) { - return { - getAttribute: () => [], - }; - } - } - - @Container.injectable() - class MockBlockchainService { - public getLastBlock() { - return mockLastBlock; - } - } - - app.bind(Container.Identifiers.DatabaseService).to(MockDatabaseService); - - app.bind(Container.Identifiers.BlockchainService).to(MockBlockchainService); - - app.bind(Container.Identifiers.WalletRepository).to(MockWalletRepository); - - app.bind(Container.Identifiers.PluginConfiguration).to(Providers.PluginConfiguration).inSingletonScope(); - app.bind(Container.Identifiers.TriggerService).to(Services.Triggers.Triggers).inSingletonScope(); - - const logger = { - error: jest.fn(), - debug: jest.fn(), - info: jest.fn(), - warning: jest.fn(), - }; - - app.bind(Container.Identifiers.LogService).toConstantValue(logger); - - const serviceProvider = app.resolve(ServiceProvider); - - const pluginConfiguration = app.get(Container.Identifiers.PluginConfiguration); - - app.resolve(ForgerService); - const client = app.resolve(Client); - const mockHost = initializeClient(client); - - const instance: Providers.PluginConfiguration = pluginConfiguration.from("core-forger", { - // @ts-ignore - hosts: [mockHost], + hosts: [], tracker: true, }); + serviceProvider.setConfig(pluginConfiguration); - serviceProvider.setConfig(instance); - - app.config( - "delegates.secrets", - calculateActiveDelegates().map((delegate) => delegate.publicKey), - ); - - app.config("app.flags", { - bip38: false, - password: null, - }); - - const spyListen = jest.fn(); - - const mockEventDispatcher = { - listen: spyListen, - }; - - app.bind(Container.Identifiers.EventDispatcherService).toConstantValue( - // @ts-ignore - mockEventDispatcher, - ); - - await expect(serviceProvider.register()).toResolve(); - await expect(serviceProvider.boot()).toResolve(); - - expect(spyListen).toHaveBeenCalledWith(Enums.BlockEvent.Applied, expect.any(DelegateTracker)); + jest.spyOn(DelegateFactory, "fromBIP39").mockReturnValue(bip39DelegateMock); + jest.spyOn(DelegateFactory, "fromBIP38").mockReturnValue(bip38DelegateMock); }); - it("boot should not initialise delegate tracker when there are no delegates", async () => { - const app: Application = new Application(new Container.Container()); - - const mockLastBlock = { - data: { height: 3, timestamp: 111150 }, - }; - - @Container.injectable() - class MockDatabaseService { - public async getActiveDelegates(): Promise { - return []; - } - } - - @Container.injectable() - class MockWalletRepository { - public findByPublicKey(publicKey: string) { - return { - getAttribute: () => [], - }; - } - } - - @Container.injectable() - class MockBlockchainService { - public getLastBlock() { - return mockLastBlock; - } - } - - app.bind(Container.Identifiers.DatabaseService).to(MockDatabaseService); - - app.bind(Container.Identifiers.BlockchainService).to(MockBlockchainService); - - app.bind(Container.Identifiers.WalletRepository).to(MockWalletRepository); - - app.bind(Container.Identifiers.PluginConfiguration).to(Providers.PluginConfiguration).inSingletonScope(); - app.bind(Container.Identifiers.TriggerService).to(Services.Triggers.Triggers).inSingletonScope(); - - const logger = { - error: jest.fn(), - debug: jest.fn(), - info: jest.fn(), - warning: jest.fn(), - }; + describe("register", () => { + it("should bind ForgerService, ForgeNewBlockAction, IsForgingAllowedAction", async () => { + expect(app.isBound(Container.Identifiers.ForgerService)).toBeFalse(); - app.bind(Container.Identifiers.LogService).toConstantValue(logger); + await serviceProvider.register(); - const serviceProvider = app.resolve(ServiceProvider); - - const pluginConfiguration = app.get(Container.Identifiers.PluginConfiguration); - - app.resolve(ForgerService); - const client = app.resolve(Client); - const mockHost = initializeClient(client); - - const instance: Providers.PluginConfiguration = pluginConfiguration.from("core-forger", { - // @ts-ignore - hosts: [mockHost], - tracker: true, - }); - - serviceProvider.setConfig(instance); - - app.config("delegates.secrets", []); - - app.config("app.flags", { - bip38: false, - password: null, + expect(app.isBound(Container.Identifiers.ForgerService)).toBeTrue(); + expect(triggerService.bind).toBeCalledTimes(2); + expect(triggerService.bind).toBeCalledWith("forgeNewBlock", expect.anything()); + expect(triggerService.bind).toBeCalledWith("isForgingAllowed", expect.anything()); }); - - const spyListen = jest.fn(); - - const mockEventDispatcher = { - listen: spyListen, - }; - - app.bind(Container.Identifiers.EventDispatcherService).toConstantValue( - // @ts-ignore - mockEventDispatcher, - ); - - await expect(serviceProvider.register()).toResolve(); - await expect(serviceProvider.boot()).toResolve(); - - expect(spyListen).not.toHaveBeenCalled(); }); - it("bootWhen should return true for bip 39 config", async () => { - const app: Application = new Application(new Container.Container()); - - app.bind(Container.Identifiers.PluginConfiguration).to(Providers.PluginConfiguration).inSingletonScope(); - app.bind(Container.Identifiers.TriggerService).to(Services.Triggers.Triggers).inSingletonScope(); - - const logger = { - error: jest.fn(), - debug: jest.fn(), - info: jest.fn(), - warning: jest.fn(), - }; - - app.bind(Container.Identifiers.LogService).toConstantValue(logger); - - const serviceProvider = app.resolve(ServiceProvider); + describe("boot", () => { + it("should call boot on forger service", async () => { + app.config("delegates", { secrets: [ "this is a super secret passphrase" ], bip38: "dummy bip 38" }); - const pluginConfiguration = app.get(Container.Identifiers.PluginConfiguration); + const forgerService = { boot: jest.fn() }; + app.bind(Container.Identifiers.ForgerService).toConstantValue(forgerService); - app.resolve(ForgerService); - const client = app.resolve(Client); - const mockHost = initializeClient(client); + await serviceProvider.boot(); - const instance: Providers.PluginConfiguration = pluginConfiguration.from("core-forger", { - // @ts-ignore - hosts: [mockHost], + expect(forgerService.boot).toBeCalledTimes(1); }); - serviceProvider.setConfig(instance); + it("should create delegates from delegates.secret and flags.bip38 / flags.password", async () => { + const secrets = [ + "this is a super secret passphrase", + "this is a super secret passphrase2" + ] + app.config("delegates", { secrets, bip38: "dummy bip 38" }); - app.config( - "delegates.secrets", - calculateActiveDelegates().map((delegate) => delegate.publicKey), - ); + const flagsConfig = { bip38: "dummy bip38", password: "dummy password" }; + app.config("app.flags", flagsConfig) - app.config("app.flags", { - bip38: false, - password: null, - }); + const forgerService = { boot: jest.fn() }; + app.bind(Container.Identifiers.ForgerService).toConstantValue(forgerService); - await expect(serviceProvider.register()).toResolve(); - await expect(serviceProvider.bootWhen()).resolves.toEqual(true); - }); - - it("bootWhen should return false for bip 38 config", async () => { - const app: Application = new Application(new Container.Container()); - - app.bind(Container.Identifiers.PluginConfiguration).to(Providers.PluginConfiguration).inSingletonScope(); - app.bind(Container.Identifiers.TriggerService).to(Services.Triggers.Triggers).inSingletonScope(); + const anotherBip39DelegateMock = { address: "D6Z26L69gdk8qYmTv5uzk3uGepigtHY4fe"} as any; + jest.spyOn(DelegateFactory, "fromBIP39").mockReturnValueOnce(anotherBip39DelegateMock); + + await serviceProvider.boot(); - const logger = { - error: jest.fn(), - debug: jest.fn(), - info: jest.fn(), - warning: jest.fn(), - }; - - app.bind(Container.Identifiers.LogService).toConstantValue(logger); - - const serviceProvider = app.resolve(ServiceProvider); - - const pluginConfiguration = app.get(Container.Identifiers.PluginConfiguration); - - app.resolve(ForgerService); - const client = app.resolve(Client); - const mockHost = initializeClient(client); - - const instance: Providers.PluginConfiguration = pluginConfiguration.from("core-forger", { - // @ts-ignore - hosts: [mockHost], + expect(forgerService.boot).toBeCalledTimes(1); + expect(forgerService.boot).toBeCalledWith([anotherBip39DelegateMock, bip39DelegateMock, bip38DelegateMock]); }); - serviceProvider.setConfig(instance); + it("should call boot on forger service with empty array when no delegates are configured", async () => { + app.config("delegates", { secrets: [], bip38: undefined }); + app.config("app", { flags: { bip38: undefined, password: undefined } }); - app.config("delegates.secrets", []); + const forgerService = { boot: jest.fn() }; + app.bind(Container.Identifiers.ForgerService).toConstantValue(forgerService); - const bip38: string = "6PYTQC4c2vBv6PGvV4HibNni6wNsHsGbR1qpL1DfkCNihsiWwXnjvJMU4B"; + await serviceProvider.boot(); - app.config("app.flags", { - bip38, - password: "bip38-password", + expect(forgerService.boot).toBeCalledTimes(1); + expect(forgerService.boot).toBeCalledWith([]); }); - - await expect(serviceProvider.register()).toResolve(); - await expect(serviceProvider.bootWhen()).resolves.toEqual(false); }); - it("dispose should of the ForgerService properly", async () => { - const app: Application = new Application(new Container.Container()); - - const mockLastBlock = { - data: { height: 3, timestamp: 111150 }, - }; + describe("dispose", () => { + it("should call dispose on forger service", async () => { + const forgerService = { dispose: jest.fn() }; + app.bind(Container.Identifiers.ForgerService).toConstantValue(forgerService); - @Container.injectable() - class MockDatabaseService { - public async getActiveDelegates(): Promise { - return []; - } - } + await serviceProvider.dispose(); - @Container.injectable() - class MockWalletRepository { - public findByPublicKey(publicKey: string) { - return { - getAttribute: () => [], - }; - } - } - - @Container.injectable() - class MockBlockchainService { - public getLastBlock() { - return mockLastBlock; - } - } - - app.bind(Container.Identifiers.DatabaseService).to(MockDatabaseService); - - app.bind(Container.Identifiers.BlockchainService).to(MockBlockchainService); - - app.bind(Container.Identifiers.WalletRepository).to(MockWalletRepository); - - app.bind(Container.Identifiers.PluginConfiguration).to(Providers.PluginConfiguration).inSingletonScope(); - app.bind(Container.Identifiers.TriggerService).to(Services.Triggers.Triggers).inSingletonScope(); - - const logger = { - error: jest.fn(), - debug: jest.fn(), - info: jest.fn(), - warning: jest.fn(), - }; - - app.bind(Container.Identifiers.LogService).toConstantValue(logger); - - const serviceProvider = app.resolve(ServiceProvider); - - const pluginConfiguration = app.get(Container.Identifiers.PluginConfiguration); - - app.resolve(ForgerService); - const client = app.resolve(Client); - const mockHost = initializeClient(client); - - const instance: Providers.PluginConfiguration = pluginConfiguration.from("core-forger", { - // @ts-ignore - hosts: [mockHost], - tracker: true, + expect(forgerService.dispose).toBeCalledTimes(1); }); + }); - serviceProvider.setConfig(instance); + describe("bootWhen", () => { + it("should return false when there is not bip38 or secrets defined", async () => { + app.config("delegates", { secrets: [], bip38: undefined }); - app.config( - "delegates.secrets", - calculateActiveDelegates().map((delegate) => delegate.publicKey), - ); + const bootWhenResult = await serviceProvider.bootWhen(); - app.config("app.flags", { - bip38: false, - password: null, + expect(bootWhenResult).toBeFalse(); }); - const spyListen = jest.fn(); + it("should return true when bip38 or secrets defined", async () => { + app.config("delegates", { secrets: [], bip38: "yeah bip 38 defined" }); - const mockEventDispatcher = { - listen: spyListen, - }; + const bootWhenResultBip38 = await serviceProvider.bootWhen(); - app.bind(Container.Identifiers.EventDispatcherService).toConstantValue( - // @ts-ignore - mockEventDispatcher, - ); + expect(bootWhenResultBip38).toBeTrue(); - await expect(serviceProvider.register()).toResolve(); - await expect(serviceProvider.boot()).toResolve(); + app.config("delegates", { secrets: [ "shhhh" ], bip38: undefined }); - const forger = app.get(Container.Identifiers.ForgerService); - const spyForgerDispose = jest.spyOn(forger, "dispose"); + const bootWhenResultSecrets = await serviceProvider.bootWhen(); - await expect(serviceProvider.dispose()).toResolve(); - await expect(spyForgerDispose).toHaveBeenCalled(); + expect(bootWhenResultSecrets).toBeTrue(); + }); }); }); diff --git a/jest.config.js b/jest.config.js index 9c7714d753..af692251c2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -18,7 +18,6 @@ module.exports = { "!packages/**/src/**/contracts/**", "!packages/**/src/**/enums/**", "!packages/**/src/**/exceptions/**", - "!packages/core-p2p/**/src/**", "!packages/crypto/**/src/**", "!**/node_modules/**", ], diff --git a/packages/core-forger/package.json b/packages/core-forger/package.json index a0c3b414a8..5721510c7f 100644 --- a/packages/core-forger/package.json +++ b/packages/core-forger/package.json @@ -28,12 +28,12 @@ "@arkecosystem/crypto": "^3.0.0-next.0", "node-forge": "^0.9.1", "otplib": "^12.0.0", - "socketcluster-client": "^14.3.1", - "wif": "^2.0.6" + "wif": "^2.0.6", + "@hapi/hapi": "^19.0.0", + "@hapi/nes": "^12.0.2" }, "devDependencies": { "@types/node-forge": "^0.9.0", - "@types/socketcluster-client": "^13.0.3", "@types/wif": "^2.0.1" }, "engines": { diff --git a/packages/core-forger/src/client.ts b/packages/core-forger/src/client.ts index 6c72306193..ead0a210f4 100644 --- a/packages/core-forger/src/client.ts +++ b/packages/core-forger/src/client.ts @@ -1,7 +1,7 @@ +import Nes from "@hapi/nes"; import { Container, Contracts, Utils } from "@arkecosystem/core-kernel"; -import { codec, NetworkState, NetworkStateStatus, socketEmit } from "@arkecosystem/core-p2p"; +import { NetworkState, NetworkStateStatus } from "@arkecosystem/core-p2p"; import { Blocks, Interfaces } from "@arkecosystem/crypto"; -import socketCluster from "socketcluster-client"; import { HostNoResponseError, RelayCommunicationError } from "./errors"; import { RelayHost } from "./interfaces"; @@ -41,20 +41,14 @@ export class Client { */ public register(hosts: RelayHost[]) { this.hosts = hosts.map((host: RelayHost) => { - host.socket = socketCluster.create({ - ...host, - autoReconnectOptions: { - initialDelay: 1000, - maxDelay: 1000, - }, - codecEngine: codec, - }); + const connection = new Nes.Client(`ws://${host.hostname}:${host.port}`); + connection.connect().catch(e => {}); // connect promise can fail when p2p is not ready, it's fine it will retry - host.socket.on("error", (err) => { - if (err.message !== "Socket hung up") { - this.logger.error(err.message); - } - }); + connection.onError = e => { + this.logger.error(e.message); + }; + + host.socket = connection; return host; }); @@ -67,7 +61,7 @@ export class Client { */ public dispose(): void { for (const host of this.hosts) { - const socket: socketCluster.SCClientSocket | undefined = host.socket; + const socket: Nes.Client | undefined = host.socket; if (socket) { socket.disconnect(); @@ -186,7 +180,7 @@ export class Client { public async selectHost(): Promise { for (let i = 0; i < 10; i++) { for (const host of this.hosts) { - if (host.socket && host.socket.getState() === host.socket.OPEN) { + if (host.socket && host.socket._isReady()) { this.host = host; return; } @@ -208,27 +202,25 @@ export class Client { * @private * @template T * @param {string} event - * @param {Record} [data={}] + * @param {Record} [payload={}] * @param {number} [timeout=4000] * @returns {Promise} * @memberof Client */ - private async emit(event: string, data: Record = {}, timeout = 4000): Promise { + private async emit(event: string, payload: Record = {}, timeout = 4000): Promise { try { - Utils.assert.defined(this.host.socket); - - const response: Contracts.P2P.Response = await socketEmit( - this.host.hostname, - this.host.socket, - event, - data, - { - "Content-Type": "application/json", - }, - timeout, - ); - - return response.data; + Utils.assert.defined(this.host.socket); + + const options = { + path: event, + headers: {}, + method: "POST", + payload + }; + + const response = await this.host.socket.request(options); + + return response.payload; } catch (error) { throw new RelayCommunicationError(`${this.host.hostname}:${this.host.port}<${event}>`, error.message); } diff --git a/packages/core-forger/src/interfaces.ts b/packages/core-forger/src/interfaces.ts index a72053b32d..6b05f812f8 100644 --- a/packages/core-forger/src/interfaces.ts +++ b/packages/core-forger/src/interfaces.ts @@ -1,5 +1,5 @@ +import Nes from "@hapi/nes"; import { Interfaces } from "@arkecosystem/crypto"; -import { SCClientSocket } from "socketcluster-client"; /** * @export @@ -19,10 +19,10 @@ export interface RelayHost { port: number; /** - * @type {SCClientSocket} + * @type {Nes.Client} * @memberof RelayHost */ - socket?: SCClientSocket; + socket?: Nes.Client; } /** diff --git a/packages/core-kernel/src/contracts/p2p/network-monitor.ts b/packages/core-kernel/src/contracts/p2p/network-monitor.ts index 5bc7717555..fcd3739e6f 100644 --- a/packages/core-kernel/src/contracts/p2p/network-monitor.ts +++ b/packages/core-kernel/src/contracts/p2p/network-monitor.ts @@ -1,5 +1,4 @@ import { Interfaces } from "@arkecosystem/crypto"; -import SocketCluster from "socketcluster"; import { NetworkState } from "./network-state"; @@ -35,9 +34,6 @@ export interface NetworkMonitor { checkNetworkHealth(): Promise; downloadBlocksFromHeight(fromBlockHeight: number, maxParallelDownloads?: number): Promise; broadcastBlock(block: Interfaces.IBlock): Promise; - getServer(): SocketCluster; // remove this - setServer(server: SocketCluster): void; // remove this isColdStart(): boolean; completeColdStart(): void; - dispose(): void; } diff --git a/packages/core-kernel/src/contracts/p2p/peer-connector.ts b/packages/core-kernel/src/contracts/p2p/peer-connector.ts index 397a90048a..655a90fd0f 100644 --- a/packages/core-kernel/src/contracts/p2p/peer-connector.ts +++ b/packages/core-kernel/src/contracts/p2p/peer-connector.ts @@ -1,20 +1,20 @@ // todo: we need to get rid of this dependency as it is big and doesn't concern core-kernel -import { SCClientSocket } from "socketcluster-client"; +import Nes from "@hapi/nes"; import { Peer } from "./peer"; export interface PeerConnector { - all(): SCClientSocket[]; + all(): Nes.Client[]; - connection(peer: Peer): SCClientSocket | undefined; + connection(peer: Peer): Nes.Client | undefined; - connect(peer: Peer, maxPayload?: number): SCClientSocket; + connect(peer: Peer, maxPayload?: number): Promise; disconnect(peer: Peer): void; terminate(peer: Peer): void; - emit(peer: Peer, event: string, data: any): void; + emit(peer: Peer, event: string, payload: any): Promise; getError(peer: Peer): string | undefined; diff --git a/packages/core-kernel/src/providers/service-provider-repository.ts b/packages/core-kernel/src/providers/service-provider-repository.ts index e506654a7e..a989578e99 100644 --- a/packages/core-kernel/src/providers/service-provider-repository.ts +++ b/packages/core-kernel/src/providers/service-provider-repository.ts @@ -28,7 +28,7 @@ export class ServiceProviderRepository { * @memberof ServiceProviderRepository */ @inject(Identifiers.EventDispatcherService) - private readonly eventDisaptcher!: EventDispatcher; + private readonly eventDispatcher!: EventDispatcher; /** * All of the registered service providers. @@ -206,7 +206,7 @@ export class ServiceProviderRepository { .whenTargetTagged("plugin", name); await serviceProvider.register(); - await this.eventDisaptcher.dispatch(KernelEvent.ServiceProviderRegistered, { name }); + await this.eventDispatcher.dispatch(KernelEvent.ServiceProviderRegistered, { name }); } /** @@ -219,7 +219,7 @@ export class ServiceProviderRepository { public async boot(name: string): Promise { await this.get(name).boot(); - await this.eventDisaptcher.dispatch(KernelEvent.ServiceProviderBooted, { name }); + await this.eventDispatcher.dispatch(KernelEvent.ServiceProviderBooted, { name }); this.loadedProviders.add(name); this.failedProviders.delete(name); @@ -236,7 +236,7 @@ export class ServiceProviderRepository { public async dispose(name: string): Promise { await this.get(name).dispose(); - await this.eventDisaptcher.dispatch(KernelEvent.ServiceProviderDisposed, { name }); + await this.eventDispatcher.dispatch(KernelEvent.ServiceProviderDisposed, { name }); this.loadedProviders.delete(name); this.failedProviders.delete(name); diff --git a/packages/core-p2p/package.json b/packages/core-p2p/package.json index 824695bd23..743f12349a 100644 --- a/packages/core-p2p/package.json +++ b/packages/core-p2p/package.json @@ -29,6 +29,10 @@ "@arkecosystem/core-transaction-pool": "^3.0.0-next.0", "@arkecosystem/crypto": "^3.0.0-next.0", "@hapi/sntp": "^4.0.0", + "@hapi/hapi": "^19.0.0", + "@hapi/nes": "air1one/nes#fix/remoteAddress", + "@hapi/joi": "^17.1.1", + "@hapi/boom": "^9.0.0", "ajv": "^6.10.2", "better-sqlite3": "^5.4.3", "dayjs": "^1.8.17", @@ -40,20 +44,14 @@ "pluralize": "^8.0.0", "pretty-ms": "^6.0.0", "rate-limiter-flexible": "^1.1.0", - "scc-broker-client": "^6.1.0", - "semver": "^6.3.0", - "socketcluster": "^14.4.2", - "socketcluster-client": "^14.3.1" + "semver": "^6.3.0" }, "devDependencies": { "@types/better-sqlite3": "^5.4.0", "@types/fs-extra": "^8.0.1", "@types/hapi__sntp": "^3.1.0", "@types/ip": "^1.1.0", - "@types/scc-broker-client": "^6.1.0", - "@types/semver": "^6.2.0", - "@types/socketcluster": "^14.0.2", - "@types/socketcluster-client": "^13.0.3" + "@types/semver": "^6.2.0" }, "engines": { "node": ">=10.x" diff --git a/packages/core-p2p/src/actions/index.ts b/packages/core-p2p/src/actions/index.ts index 63b4d3df9e..74e5e9878b 100644 --- a/packages/core-p2p/src/actions/index.ts +++ b/packages/core-p2p/src/actions/index.ts @@ -1 +1 @@ -export { ValidateAndAcceptPeerAction } from "./validate-and-accept-peer" +export { ValidateAndAcceptPeerAction } from "./validate-and-accept-peer"; diff --git a/packages/core-p2p/src/actions/validate-and-accept-peer.ts b/packages/core-p2p/src/actions/validate-and-accept-peer.ts index 5018e3ed8a..e9f9d4b506 100644 --- a/packages/core-p2p/src/actions/validate-and-accept-peer.ts +++ b/packages/core-p2p/src/actions/validate-and-accept-peer.ts @@ -11,8 +11,8 @@ export class ValidateAndAcceptPeerAction extends Services.Triggers.Action { } public async execute(args: ActionArguments): Promise { - let peer: Contracts.P2P.Peer = args.peer; - let options: Contracts.P2P.AcceptNewPeerOptions = args.options; + const peer: Contracts.P2P.Peer = args.peer; + const options: Contracts.P2P.AcceptNewPeerOptions = args.options; return this.app.get(Container.Identifiers.PeerProcessor).validateAndAcceptPeer(peer, options); } diff --git a/packages/core-p2p/src/defaults.ts b/packages/core-p2p/src/defaults.ts index d13be25afb..d7fa8065ea 100644 --- a/packages/core-p2p/src/defaults.ts +++ b/packages/core-p2p/src/defaults.ts @@ -1,5 +1,4 @@ export const defaults = { - // https://socketcluster.io/#!/docs/api-socketcluster server: { hostname: process.env.CORE_P2P_HOST || "0.0.0.0", port: process.env.CORE_P2P_PORT || 4002, @@ -65,7 +64,7 @@ export const defaults = { */ ntp: ["pool.ntp.org", "time.google.com"], /** - * Rate limit config, used in socket-server worker / master + * Rate limit config */ rateLimit: process.env.CORE_P2P_RATE_LIMIT || 100, // max number of messages per second per socket connection }; diff --git a/packages/core-p2p/src/event-listener.ts b/packages/core-p2p/src/event-listener.ts index 79a5470ce5..5f1c5acc6f 100644 --- a/packages/core-p2p/src/event-listener.ts +++ b/packages/core-p2p/src/event-listener.ts @@ -11,15 +11,7 @@ export class EventListener { @Container.inject(Container.Identifiers.EventDispatcherService) private readonly emitter!: Contracts.Kernel.EventDispatcher; - @Container.inject(Container.Identifiers.PeerNetworkMonitor) - private readonly networkMonitor!: Contracts.P2P.NetworkMonitor; - public initialize() { this.emitter.listen(Enums.PeerEvent.Disconnect, this.app.resolve(DisconnectPeer)); - - const exitHandler = () => this.networkMonitor.dispose(); - - process.on("SIGINT", exitHandler); - process.on("exit", exitHandler); } } diff --git a/packages/core-p2p/src/network-monitor.ts b/packages/core-p2p/src/network-monitor.ts index 116188614b..af9776d432 100644 --- a/packages/core-p2p/src/network-monitor.ts +++ b/packages/core-p2p/src/network-monitor.ts @@ -1,7 +1,6 @@ -import { Container, Contracts, Enums, Providers, Utils, Services } from "@arkecosystem/core-kernel"; +import { Container, Contracts, Enums, Providers, Services, Utils } from "@arkecosystem/core-kernel"; import { Interfaces } from "@arkecosystem/crypto"; import prettyMs from "pretty-ms"; -import SocketCluster from "socketcluster"; import { NetworkState } from "./network-state"; import { PeerCommunicator } from "./peer-communicator"; @@ -11,7 +10,6 @@ import { buildRateLimiter, checkDNS, checkNTP } from "./utils"; // todo: review the implementation @Container.injectable() export class NetworkMonitor implements Contracts.P2P.NetworkMonitor { - public server: SocketCluster | undefined; public config: any; public nextUpdateNetworkStatusScheduled: boolean | undefined; private coldStart: boolean = false; @@ -56,15 +54,6 @@ export class NetworkMonitor implements Contracts.P2P.NetworkMonitor { this.rateLimiter = buildRateLimiter(this.config); } - public getServer(): SocketCluster { - // @ts-ignore - return this.server; - } - - public setServer(server: SocketCluster): void { - this.server = server; - } - public async boot(): Promise { await this.checkDNSConnectivity(this.config.dns); await this.checkNTPConnectivity(this.config.ntp); @@ -90,14 +79,6 @@ export class NetworkMonitor implements Contracts.P2P.NetworkMonitor { this.initializing = false; } - public dispose(): void { - if (this.server) { - this.server.removeAllListeners(); - this.server.destroy(); - this.server = undefined; - } - } - public async updateNetworkStatus(initialRun?: boolean): Promise { if (process.env.NODE_ENV === "test") { return; @@ -220,10 +201,13 @@ export class NetworkMonitor implements Contracts.P2P.NetworkMonitor { ); if (pingAll || !this.hasMinimumPeers() || ownPeers.length < theirPeers.length * 0.75) { - await Promise.all(theirPeers.map((p) => this.app - .get(Container.Identifiers.TriggerService) - .call("validateAndAcceptPeer", { peer: p, options: { lessVerbose: true }}) - )); + await Promise.all( + theirPeers.map((p) => + this.app + .get(Container.Identifiers.TriggerService) + .call("validateAndAcceptPeer", { peer: p, options: { lessVerbose: true } }), + ), + ); this.pingPeerPorts(pingAll); return true; @@ -591,7 +575,7 @@ export class NetworkMonitor implements Contracts.P2P.NetworkMonitor { if (!peerList.find((p) => p.ip === peer.ip)) { peerList.push({ ip: peer.ip, - ports: { "@arkecosystem/core-api": peer.port }, + ports: { "@arkecosystem/core-p2p": peer.port }, version: this.app.version(), }); } @@ -614,7 +598,7 @@ export class NetworkMonitor implements Contracts.P2P.NetworkMonitor { return this.app .get(Container.Identifiers.TriggerService) - .call("validateAndAcceptPeer", { peer, options: { seed: true, lessVerbose: true } }); + .call("validateAndAcceptPeer", { peer, options: { seed: true, lessVerbose: true } }); }), ); } diff --git a/packages/core-p2p/src/peer-communicator.ts b/packages/core-p2p/src/peer-communicator.ts index f1bc557cde..7c4de90b09 100644 --- a/packages/core-p2p/src/peer-communicator.ts +++ b/packages/core-p2p/src/peer-communicator.ts @@ -2,7 +2,6 @@ import { Container, Contracts, Enums, Providers, Utils } from "@arkecosystem/cor import { Blocks, Interfaces, Managers, Transactions, Validation } from "@arkecosystem/crypto"; import dayjs from "dayjs"; import delay from "delay"; -import { SCClientSocket } from "socketcluster-client"; import { constants } from "./constants"; import { SocketErrors } from "./enums"; @@ -10,7 +9,7 @@ import { PeerPingTimeoutError, PeerStatusResponseError, PeerVerificationFailedEr import { PeerVerifier } from "./peer-verifier"; import { RateLimiter } from "./rate-limiter"; import { replySchemas } from "./schemas"; -import { buildRateLimiter, isValidVersion, socketEmit } from "./utils"; +import { buildRateLimiter, isValidVersion } from "./utils"; // todo: review the implementation @Container.injectable() @@ -74,7 +73,7 @@ export class PeerCommunicator implements Contracts.P2P.PeerCommunicator { const pingResponse: Contracts.P2P.PeerPingResponse = await this.emit( peer, "p2p.peer.getStatus", - undefined, + {}, getStatusTimeout, ); @@ -166,7 +165,7 @@ export class PeerCommunicator implements Contracts.P2P.PeerCommunicator { this.logger.debug(`Fetching a fresh peer list from ${peer.url}`); const getPeersTimeout = 5000; - return this.emit(peer, "p2p.peer.getPeers", undefined, getPeersTimeout); + return this.emit(peer, "p2p.peer.getPeers", {}, getPeersTimeout); } public async hasCommonBlocks(peer: Contracts.P2P.Peer, ids: string[], timeoutMsec?: number): Promise { @@ -236,7 +235,7 @@ export class PeerCommunicator implements Contracts.P2P.PeerCommunicator { } private parseHeaders(peer: Contracts.P2P.Peer, response): void { - if (response.headers.height) { + if (response.headers && response.headers.height) { peer.state.height = +response.headers.height; } } @@ -260,7 +259,7 @@ export class PeerCommunicator implements Contracts.P2P.PeerCommunicator { return true; } - private async emit(peer: Contracts.P2P.Peer, event: string, data?: any, timeout?: number, maxPayload?: number) { + private async emit(peer: Contracts.P2P.Peer, event: string, payload: any, timeout?: number, maxPayload?: number) { await this.throttle(peer, event); let response; @@ -270,22 +269,14 @@ export class PeerCommunicator implements Contracts.P2P.PeerCommunicator { const timeBeforeSocketCall: number = new Date().getTime(); maxPayload = maxPayload || 100 * constants.KILOBYTE; // 100KB by default, enough for most requests - const connection: SCClientSocket = this.connector.connect(peer, maxPayload); - response = await socketEmit( - peer.ip, - connection, - event, - data, - { - "Content-Type": "application/json", - }, - timeout, - ); + await this.connector.connect(peer, maxPayload); + + response = await this.connector.emit(peer, event, payload); peer.latency = new Date().getTime() - timeBeforeSocketCall; - this.parseHeaders(peer, response); + this.parseHeaders(peer, response.payload); - if (!this.validateReply(peer, response.data, event)) { + if (!this.validateReply(peer, response.payload, event)) { throw new Error(`Response validation failed from peer ${peer.ip} : ${JSON.stringify(response.data)}`); } } catch (e) { @@ -293,7 +284,7 @@ export class PeerCommunicator implements Contracts.P2P.PeerCommunicator { return undefined; } - return response.data; + return response.payload; } private async throttle(peer: Contracts.P2P.Peer, event: string): Promise { diff --git a/packages/core-p2p/src/peer-connector.ts b/packages/core-p2p/src/peer-connector.ts index b7808d4c05..32dee36628 100644 --- a/packages/core-p2p/src/peer-connector.ts +++ b/packages/core-p2p/src/peer-connector.ts @@ -1,35 +1,25 @@ -import { Container, Contracts, Providers, Utils } from "@arkecosystem/core-kernel"; -import { create, SCClientSocket } from "socketcluster-client"; - -import { codec } from "./utils/sc-codec"; +import { Container, Contracts, Utils } from "@arkecosystem/core-kernel"; +import Nes from "@hapi/nes"; +import os from "os"; // todo: review the implementation @Container.injectable() export class PeerConnector implements Contracts.P2P.PeerConnector { - @Container.inject(Container.Identifiers.PluginConfiguration) - @Container.tagged("plugin", "@arkecosystem/core-p2p") - private readonly configuration!: Providers.PluginConfiguration; - - private readonly connections: Utils.Collection = new Utils.Collection(); + private readonly connections: Utils.Collection = new Utils.Collection(); private readonly errors: Map = new Map(); - public all(): SCClientSocket[] { + public all(): Nes.Client[] { return this.connections.values(); } - public connection(peer: Contracts.P2P.Peer): SCClientSocket | undefined { - const connection: SCClientSocket | undefined = this.connections.get(peer.ip); + public connection(peer: Contracts.P2P.Peer): Nes.Client | undefined { + const connection: Nes.Client | undefined = this.connections.get(peer.ip); return connection; } - public connect(peer: Contracts.P2P.Peer, maxPayload?: number): SCClientSocket { - const connection = this.connection(peer) || this.create(peer); - - const socket = (connection as any).transport.socket; - if (maxPayload && socket._receiver) { - socket._receiver._maxPayload = maxPayload; - } + public async connect(peer: Contracts.P2P.Peer, maxPayload?: number): Promise { + const connection = this.connection(peer) || (await this.create(peer)); this.connections.set(peer.ip, connection); @@ -40,7 +30,7 @@ export class PeerConnector implements Contracts.P2P.PeerConnector { const connection = this.connection(peer); if (connection) { - connection.destroy(); + connection.disconnect(); this.connections.forget(peer.ip); } @@ -50,16 +40,38 @@ export class PeerConnector implements Contracts.P2P.PeerConnector { const connection = this.connection(peer); if (connection) { - (connection as any).transport.socket.terminate(); + connection.transport.socket.terminate(); this.connections.forget(peer.ip); } } - public emit(peer: Contracts.P2P.Peer, event: string, data: any): void { - // TODO is this method revelant here ? :think: - const connection: SCClientSocket = this.connect(peer); - connection.emit(event, data); + public async emit(peer: Contracts.P2P.Peer, event: string, payload: any): Promise { + const ifaces = os.networkInterfaces(); + + const ipHeader = Object.values(ifaces) + .reduce((finalifaces, arrIface) => [...finalifaces, ...arrIface], []) + .filter((iface) => { + if ("IPv4" !== iface.family || iface.internal !== false) { + // skip over internal (i.e. 127.0.0.1) and non-ipv4 addresses + return false; + } + return true; + }) + .map((iface) => iface.address) + .join(); + + const connection: Nes.Client = await this.connect(peer); + const options = { + path: event, + headers: { + "x-forwarded-for": ipHeader, + }, + method: "POST", + payload, + }; + + return connection.request(options); } public getError(peer: Contracts.P2P.Peer): string | undefined { @@ -78,40 +90,9 @@ export class PeerConnector implements Contracts.P2P.PeerConnector { this.errors.delete(peer.ip); } - private create(peer: Contracts.P2P.Peer): SCClientSocket { - const getBlocksTimeout = this.configuration.getRequired("getBlocksTimeout"); - const verifyTimeout = this.configuration.getRequired("verifyTimeout"); - - const connection = create({ - port: peer.port, - hostname: peer.ip, - ackTimeout: Math.max(getBlocksTimeout, verifyTimeout), - perMessageDeflate: false, - codecEngine: codec, - }); - - const socket = (connection as any).transport.socket; - - socket.on("ping", () => this.terminate(peer)); - socket.on("pong", () => this.terminate(peer)); - socket.on("message", (data) => { - if (data === "#1") { - // this is to establish some rate limit on #1 messages - // a simple rate limit of 1 per second doesnt seem to be enough, so decided to give some margin - // and allow up to 10 per second which should be more than enough - const timeNow: number = new Date().getTime(); - socket._last10Pings = socket._last10Pings || []; - socket._last10Pings.push(timeNow); - if (socket._last10Pings.length >= 10) { - socket._last10Pings = socket._last10Pings.slice(socket._last10Pings.length - 10); - if (timeNow - socket._last10Pings[0] < 1000) { - this.terminate(peer); - } - } - } - }); - - connection.on("error", () => this.disconnect(peer)); + private async create(peer: Contracts.P2P.Peer): Promise { + const connection = new Nes.Client(`ws://${peer.ip}:${peer.port}`); + await connection.connect(); return connection; } diff --git a/packages/core-p2p/src/peer-verifier.ts b/packages/core-p2p/src/peer-verifier.ts index 7376c2c395..a9fd78c45f 100644 --- a/packages/core-p2p/src/peer-verifier.ts +++ b/packages/core-p2p/src/peer-verifier.ts @@ -1,5 +1,5 @@ import { DatabaseService } from "@arkecosystem/core-database"; -import { Container, Contracts, Utils, Services } from "@arkecosystem/core-kernel"; +import { Container, Contracts, Services, Utils } from "@arkecosystem/core-kernel"; import { Blocks, Interfaces } from "@arkecosystem/crypto"; import assert from "assert"; import pluralize from "pluralize"; diff --git a/packages/core-p2p/src/schemas.ts b/packages/core-p2p/src/schemas.ts index 17483a8eb6..900f131e6b 100644 --- a/packages/core-p2p/src/schemas.ts +++ b/packages/core-p2p/src/schemas.ts @@ -256,7 +256,7 @@ export const replySchemas = { }, }, "p2p.peer.postBlock": { - type: "object", + type: "boolean", }, "p2p.peer.postTransactions": { type: "array", diff --git a/packages/core-p2p/src/service-provider.ts b/packages/core-p2p/src/service-provider.ts index d54c4b54eb..bd2308f34b 100644 --- a/packages/core-p2p/src/service-provider.ts +++ b/packages/core-p2p/src/service-provider.ts @@ -1,4 +1,4 @@ -import { Container, Providers, Services } from "@arkecosystem/core-kernel"; +import { Container, Providers, Services, Types, Utils } from "@arkecosystem/core-kernel"; import { ValidateAndAcceptPeerAction } from "./actions"; import { EventListener } from "./event-listener"; @@ -8,11 +8,12 @@ import { PeerCommunicator } from "./peer-communicator"; import { PeerConnector } from "./peer-connector"; import { PeerProcessor } from "./peer-processor"; import { PeerStorage } from "./peer-storage"; -import { startSocketServer } from "./socket-server"; -import { payloadProcessor } from "./socket-server/payload-processor"; +import { Server } from "./socket-server/server"; import { TransactionBroadcaster } from "./transaction-broadcaster"; export class ServiceProvider extends Providers.ServiceProvider { + private serverSymbol = Symbol.for("P2P"); + public async register(): Promise { this.registerFactories(); @@ -24,25 +25,31 @@ export class ServiceProvider extends Providers.ServiceProvider { return; } - this.app.get(Container.Identifiers.PeerNetworkMonitor).setServer( - await startSocketServer( - this.app, - { - storage: this.app.get(Container.Identifiers.PeerStorage), - connector: this.app.get(Container.Identifiers.PeerConnector), - communicator: this.app.get(Container.Identifiers.PeerCommunicator), - processor: this.app.get(Container.Identifiers.PeerProcessor), - networkMonitor: this.app.get(Container.Identifiers.PeerNetworkMonitor), - }, - this.config().all(), - ), - ); - - payloadProcessor.initialize(); + await this.buildServer(this.serverSymbol); + } + + /** + * @returns {Promise} + * @memberof ServiceProvider + */ + public async bootWhen(): Promise { + return !process.env.DISABLE_P2P_SERVER + } + + /** + * @returns {Promise} + * @memberof ServiceProvider + */ + public async boot(): Promise { + return this.app.get(this.serverSymbol).boot(); } public async dispose(): Promise { - this.app.get(Container.Identifiers.PeerNetworkMonitor).dispose(); + if (process.env.DISABLE_P2P_SERVER) { + return; + } + + this.app.get(this.serverSymbol).dispose(); } public async required(): Promise { @@ -77,6 +84,16 @@ export class ServiceProvider extends Providers.ServiceProvider { this.app.bind(Container.Identifiers.PeerTransactionBroadcaster).to(TransactionBroadcaster); } + private async buildServer(id: symbol): Promise { + this.app.bind(id).to(Server).inSingletonScope(); + + const server: Server = this.app.get(id); + const serverConfig = this.config().get("server"); + Utils.assert.defined(serverConfig); + + await server.initialize("P2P Server", serverConfig); + } + private registerActions(): void { this.app .get(Container.Identifiers.TriggerService) diff --git a/packages/core-p2p/src/socket-server/controllers/controller.ts b/packages/core-p2p/src/socket-server/controllers/controller.ts new file mode 100644 index 0000000000..ab1020dc98 --- /dev/null +++ b/packages/core-p2p/src/socket-server/controllers/controller.ts @@ -0,0 +1,19 @@ +import Joi from "@hapi/joi"; +import Boom from "@hapi/boom"; +import { Container, Contracts } from "@arkecosystem/core-kernel"; + +@Container.injectable() +export class Controller { + @Container.inject(Container.Identifiers.Application) + protected readonly app!: Contracts.Kernel.Application; + + @Container.inject(Container.Identifiers.LogService) + protected readonly logger!: Contracts.Kernel.Logger; + + protected validatePayload(payload: any, schema: Joi.Schema): void { + const { error } = schema.validate(payload); + if (error) { + throw Boom.badRequest("Validation failed"); + } + } +} diff --git a/packages/core-p2p/src/socket-server/controllers/internal.ts b/packages/core-p2p/src/socket-server/controllers/internal.ts new file mode 100644 index 0000000000..f822570af3 --- /dev/null +++ b/packages/core-p2p/src/socket-server/controllers/internal.ts @@ -0,0 +1,94 @@ +import { DatabaseService } from "@arkecosystem/core-database"; +import { Container, Contracts, Utils } from "@arkecosystem/core-kernel"; +import { Crypto, Interfaces, Managers } from "@arkecosystem/crypto"; +import Hapi from "@hapi/hapi"; + +import { Controller } from "./controller"; + +export class InternalController extends Controller { + @Container.inject(Container.Identifiers.PeerProcessor) + private readonly peerProcessor!: Contracts.P2P.PeerProcessor; + + @Container.inject(Container.Identifiers.PeerNetworkMonitor) + private readonly peerNetworkMonitor!: Contracts.P2P.NetworkMonitor; + + @Container.inject(Container.Identifiers.EventDispatcherService) + private readonly eventDispatcher!: Contracts.Kernel.EventDispatcher; + + @Container.inject(Container.Identifiers.DatabaseService) + private readonly database!: DatabaseService; + + public async acceptNewPeer(request: Hapi.Request, h: Hapi.ResponseToolkit): Promise { + return this.peerProcessor.validateAndAcceptPeer({ ip: (request.payload as any).ip } as Contracts.P2P.Peer); + } + + public emitEvent(request: Hapi.Request, h: Hapi.ResponseToolkit): boolean { + this.eventDispatcher.dispatch((request.payload as any).event, (request.payload as any).body); + return true; + } + + public async getUnconfirmedTransactions( + request: Hapi.Request, + h: Hapi.ResponseToolkit, + ): Promise { + const collator: Contracts.TransactionPool.Collator = this.app.get( + Container.Identifiers.TransactionPoolCollator, + ); + const transactionPool: Contracts.TransactionPool.Service = this.app.get( + Container.Identifiers.TransactionPoolService, + ); + const transactions: Interfaces.ITransaction[] = await collator.getBlockCandidateTransactions(); + + return { + poolSize: transactionPool.getPoolSize(), + transactions: transactions.map((t) => t.serialized.toString("hex")), + }; + } + + public async getCurrentRound(request: Hapi.Request, h: Hapi.ResponseToolkit): Promise { + const blockchain = this.app.get(Container.Identifiers.BlockchainService); + const lastBlock = blockchain.getLastBlock(); + + const height = lastBlock.data.height + 1; + const roundInfo = Utils.roundCalculator.calculateRound(height); + const { maxDelegates, round } = roundInfo; + + const blockTime = Managers.configManager.getMilestone(height).blocktime; + const reward = Managers.configManager.getMilestone(height).reward; + const delegates: Contracts.P2P.DelegateWallet[] = (await this.database.getActiveDelegates(roundInfo)).map( + (wallet) => ({ + ...wallet, + delegate: wallet.getAttribute("delegate"), + }), + ); + + const timestamp = Crypto.Slots.getTime(); + const blockTimestamp = Crypto.Slots.getSlotNumber(timestamp) * blockTime; + const currentForger = parseInt((timestamp / blockTime) as any) % maxDelegates; + const nextForger = (parseInt((timestamp / blockTime) as any) + 1) % maxDelegates; + + return { + current: round, + reward, + timestamp: blockTimestamp, + delegates, + currentForger: delegates[currentForger], + nextForger: delegates[nextForger], + lastBlock: lastBlock.data, + canForge: parseInt((1 + lastBlock.data.timestamp / blockTime) as any) * blockTime < timestamp - 1, + }; + } + + public async getNetworkState(request: Hapi.Request, h: Hapi.ResponseToolkit): Promise { + return this.peerNetworkMonitor.getNetworkState(); + } + + public syncBlockchain(request: Hapi.Request, h: Hapi.ResponseToolkit): boolean { + this.logger.debug("Blockchain sync check WAKEUP requested by forger"); + + const blockchain = this.app.get(Container.Identifiers.BlockchainService); + blockchain.forceWakeup(); + + return true; + } +} diff --git a/packages/core-p2p/src/socket-server/controllers/peer.ts b/packages/core-p2p/src/socket-server/controllers/peer.ts new file mode 100644 index 0000000000..2672f2b1bf --- /dev/null +++ b/packages/core-p2p/src/socket-server/controllers/peer.ts @@ -0,0 +1,166 @@ +import { DatabaseService } from "@arkecosystem/core-database"; +import { Container, Contracts, Providers, Utils } from "@arkecosystem/core-kernel"; +import { Blocks, Crypto, Interfaces, Managers } from "@arkecosystem/crypto"; +import Hapi from "@hapi/hapi"; + +import { MissingCommonBlockError } from "../../errors"; +import { isWhitelisted } from "../../utils"; +import { TooManyTransactionsError, UnchainedBlockError } from "../errors"; +import { getPeerConfig } from "../utils/get-peer-config"; +import { mapAddr } from "../utils/map-addr"; +import { Controller } from "./controller"; + +export class PeerController extends Controller { + @Container.inject(Container.Identifiers.PeerStorage) + private readonly peerStorage!: Contracts.P2P.PeerStorage; + + @Container.inject(Container.Identifiers.DatabaseService) + private readonly database!: DatabaseService; + + public getPeers(request: Hapi.Request, h: Hapi.ResponseToolkit): Contracts.P2P.PeerBroadcast[] { + return this.peerStorage + .getPeers() + .map((peer) => peer.toBroadcast()) + .sort((a, b) => { + Utils.assert.defined(a.latency); + Utils.assert.defined(b.latency); + + return a.latency - b.latency; + }); + } + + public async getCommonBlocks( + request: Hapi.Request, + h: Hapi.ResponseToolkit, + ): Promise<{ + common: Interfaces.IBlockData; + lastBlockHeight: number; + }> { + const commonBlocks: Interfaces.IBlockData[] = await this.database.getCommonBlocks((request.payload as any).ids); + + if (!commonBlocks.length) { + throw new MissingCommonBlockError(); + } + + const blockchain = this.app.get(Container.Identifiers.BlockchainService); + return { + common: commonBlocks[0], + lastBlockHeight: blockchain.getLastBlock().data.height, + }; + } + + public async getStatus(request: Hapi.Request, h: Hapi.ResponseToolkit): Promise { + const blockchain = this.app.get(Container.Identifiers.BlockchainService); + const lastBlock: Interfaces.IBlock = blockchain.getLastBlock(); + + return { + state: { + height: lastBlock ? lastBlock.data.height : 0, + forgingAllowed: Crypto.Slots.isForgingAllowed(), + currentSlot: Crypto.Slots.getSlotNumber(), + header: lastBlock ? lastBlock.getHeader() : {}, + }, + config: getPeerConfig(this.app), + }; + } + + public postBlock(request: Hapi.Request, h: Hapi.ResponseToolkit): boolean { + const configuration = this.app.getTagged( + Container.Identifiers.PluginConfiguration, + "plugin", + "@arkecosystem/core-p2p", + ); + + const blockBuffer = Buffer.from(request.payload.block.data); + const blockHex: string = blockBuffer.toString("hex"); + + const deserializedHeader = Blocks.Deserializer.deserialize(blockHex, true); + + if ( + deserializedHeader.data.numberOfTransactions > Managers.configManager.getMilestone().block.maxTransactions + ) { + throw new TooManyTransactionsError(deserializedHeader.data); + } + + const deserialized: { + data: Interfaces.IBlockData; + transactions: Interfaces.ITransaction[]; + } = Blocks.Deserializer.deserialize(blockHex); + + const block: Interfaces.IBlockData = { + ...deserialized.data, + transactions: deserialized.transactions.map((tx) => tx.data), + }; + + const fromForger: boolean = isWhitelisted( + configuration.getOptional("remoteAccess", []), + request.info.remoteAddress, + ); + + const blockchain = this.app.get(Container.Identifiers.BlockchainService); + + if (!fromForger) { + if (blockchain.pingBlock(block)) { + return true; + } + + const lastDownloadedBlock: Interfaces.IBlockData = blockchain.getLastDownloadedBlock(); + + if (!Utils.isBlockChained(lastDownloadedBlock, block)) { + throw new UnchainedBlockError(lastDownloadedBlock.height, block.height); + } + } + + if ( + block.transactions && + block.transactions.length > Managers.configManager.getMilestone().block.maxTransactions + ) { + throw new TooManyTransactionsError(block); + } + + this.logger.info( + `Received new block at height ${block.height.toLocaleString()} with ${Utils.pluralize( + "transaction", + block.numberOfTransactions, + true, + )} from ${request.info.remoteAddress} (${request.headers.host})`, + ); + + blockchain.handleIncomingBlock(block, fromForger); + return true; + } + + public async postTransactions(request: Hapi.Request, h: Hapi.ResponseToolkit): Promise { + const createProcessor: Contracts.TransactionPool.ProcessorFactory = this.app.get( + Container.Identifiers.TransactionPoolProcessorFactory, + ); + const processor: Contracts.TransactionPool.Processor = createProcessor(); + await processor.process((request.payload as any).transactions); + return processor.accept; + } + + public async getBlocks( + request: Hapi.Request, + h: Hapi.ResponseToolkit, + ): Promise { + const reqBlockHeight: number = +(request.payload as any).lastBlockHeight + 1; + const reqBlockLimit: number = +(request.payload as any).blockLimit || 400; + const reqHeadersOnly: boolean = !!(request.payload as any).headersOnly; + + const blocks: Contracts.Shared.DownloadBlock[] = await this.database.getBlocksForDownload( + reqBlockHeight, + reqBlockLimit, + reqHeadersOnly, + ); + + this.logger.info( + `${mapAddr(request.info.remoteAddress)} has downloaded ${Utils.pluralize( + "block", + blocks.length, + true, + )} from height ${reqBlockHeight.toLocaleString()}`, + ); + + return blocks; + } +} diff --git a/packages/core-p2p/src/socket-server/index.ts b/packages/core-p2p/src/socket-server/index.ts deleted file mode 100644 index b0e161a3e5..0000000000 --- a/packages/core-p2p/src/socket-server/index.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { Container, Contracts, Providers } from "@arkecosystem/core-kernel"; -import { Managers } from "@arkecosystem/crypto"; -import SocketCluster from "socketcluster"; - -import { PeerService } from "../contracts"; -import { requestSchemas } from "../schemas"; -import { ServerError } from "./errors"; -import { payloadProcessor } from "./payload-processor"; -import { getHeaders } from "./utils/get-headers"; -import { validate } from "./utils/validate"; -import * as handlers from "./versions"; - -// todo: review implementation -export const startSocketServer = async ( - app: Contracts.Kernel.Application, - service: PeerService, - config: Record, -): Promise => { - // when testing we also need to get socket files from dist folder - // todo: get rid of thise, no test vars in production code - const relativeSocketPath = process.env.CORE_ENV === "test" ? "/../../dist/socket-server" : ""; - - const configuration = app.getTagged( - Container.Identifiers.PluginConfiguration, - "plugin", - "@arkecosystem/core-p2p", - ); - const getBlocksTimeout = configuration.getRequired("getBlocksTimeout"); - const verifyTimeout = configuration.getRequired("verifyTimeout"); - const blockMaxPayload = Managers.configManager - .getMilestones() - .reduce((acc, curr) => Math.max(acc, (curr.block || {}).maxPayload || 0), 0); - // we don't have current height so use max value of maxPayload defined in milestones - - // https://socketcluster.io/#!/docs/api-socketcluster - const server: SocketCluster = new SocketCluster({ - ...{ - appName: "core-p2p", - brokers: 1, - environment: process.env.CORE_NETWORK_NAME === "testnet" ? "dev" : "prod", - rebootWorkerOnCrash: true, - workerController: __dirname + `${relativeSocketPath}/worker.js`, - workers: 2, - wsEngine: "ws", - // See https://github.com/SocketCluster/socketcluster/issues/506 about - // details on how pingTimeout works. - pingTimeout: Math.max(getBlocksTimeout, verifyTimeout), - perMessageDeflate: false, - maxPayload: blockMaxPayload + 10 * 1024, // 10KB margin vs block maxPayload to allow few additional chars for p2p message - }, - ...config.server, - }); - - server.on("fail", (data) => app.log.error(data.message)); - - // socketcluster types do not allow on("workerMessage") so casting as any - (server as any).on("workerMessage", async (workerId, req, res) => { - const [, version, method] = req.endpoint.split("."); - - try { - if (requestSchemas[version]) { - const requestSchema = requestSchemas[version][method]; - - // data of type Buffer is ser/deserialized into { type: "Buffer", data } object - // when it is sent from worker to master. - // here we transform those back to Buffer (only 1st level properties). - for (const key of Object.keys(req.data)) { - if ( - req.data[key] && // avoids values like null - typeof req.data[key] === "object" && - req.data[key].type === "Buffer" && - req.data[key].data - ) { - req.data[key] = Buffer.from(req.data[key].data); - } - } - if (requestSchema) { - validate(requestSchema, req.data); - } - } - - return res(undefined, { - data: (await handlers[version][method]({ app, service, req })) || {}, - headers: getHeaders(app), - }); - } catch (error) { - if (error instanceof ServerError) { - return res(error); - } - - app.log.error(error.message); - return res(new Error(`${req.endpoint} responded with ${error.message}`)); - } - }); - - // Create a timeout promise so that if socket server is not ready in 10 seconds, it rejects - const timeout: NodeJS.Timeout = setTimeout(() => { - throw new Error("Socket server failed to setup in 10 seconds."); - }, 10000); - - const serverReadyPromise = await new Promise((resolve) => server.on("ready", () => resolve(server))); - - clearTimeout(timeout); - - payloadProcessor.inject(server); - - return serverReadyPromise; -}; diff --git a/packages/core-p2p/src/socket-server/payload-processor.ts b/packages/core-p2p/src/socket-server/payload-processor.ts deleted file mode 100644 index 25d88c10df..0000000000 --- a/packages/core-p2p/src/socket-server/payload-processor.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { Container, Contracts, Utils } from "@arkecosystem/core-kernel"; -import sqlite3 from "better-sqlite3"; -import { ensureFileSync, existsSync, unlinkSync } from "fs-extra"; -import SocketCluster from "socketcluster"; - -import { getHeaders } from "./utils/get-headers"; - -// todo: review the implementation or trash it in favour of a proper implementation -@Container.injectable() -class PayloadProcessor { - @Container.inject(Container.Identifiers.Application) - private readonly app!: Contracts.Kernel.Application; - - private payloadDatabasePath = `${process.env.CORE_PATH_DATA}/transactions-received.sqlite`; - - private databaseSize: number = (500 * 1024 * 1024) / 4096; // 500 MB - private payloadDatabase!: sqlite3.Database; - private payloadQueue: any[] = []; - private payloadOverflowQueue: any[] = []; - private maxPayloadQueueSize = 100; - private maxPayloadOverflowQueueSize = 50; - private listener: any; - - public initialize() { - if (existsSync(this.payloadDatabasePath)) { - unlinkSync(this.payloadDatabasePath); - } - - ensureFileSync(this.payloadDatabasePath); - - this.payloadDatabase = new sqlite3(this.payloadDatabasePath); - this.payloadDatabase.exec(` - PRAGMA auto_vacuum = FULL; - PRAGMA journal_mode = OFF; - PRAGMA max_page_count = ${this.databaseSize}; - CREATE TABLE IF NOT EXISTS payloads (id INTEGER PRIMARY KEY, payload BLOB NOT NULL) - `); - this.payloadDatabase.exec("DELETE FROM payloads"); - } - - public inject(socketCluster: SocketCluster): void { - this.listener = socketCluster.listeners("workerMessage")[0]; - socketCluster.removeListener("workerMessage", this.listener); - - (socketCluster as any).on("workerMessage", async (workerId, req, res) => { - if (req.endpoint === "p2p.peer.postTransactions") { - this.addPayload({ workerId, req }); - if (this.totalPayloads() === 1) { - this.processPayloads(); - } - return res(undefined, { - data: [], - headers: getHeaders(this.app), - }); - } - return await this.listener(workerId, req, res); - }); - } - - private async addPayload(payload) { - if (this.payloadQueue.length >= this.maxPayloadQueueSize) { - this.payloadOverflowQueue.push(payload); - if (this.payloadOverflowQueue.length >= this.maxPayloadOverflowQueueSize) { - let overflowQueueSize = this.payloadOverflowQueue.length; - try { - const query = this.payloadDatabase.prepare("INSERT INTO payloads (payload) VALUES (:payload)"); - const saveToDB = this.payloadDatabase.transaction((data) => { - for (const overflowingPayload of data) { - query.run({ payload: JSON.stringify(overflowingPayload) }); - overflowQueueSize--; - } - }); - saveToDB(this.payloadOverflowQueue); - } catch (error) { - this.app.log.warning( - `Discarding ${Utils.pluralize( - "transaction payload", - overflowQueueSize, - true, - )} that could not be added to the disk storage`, - ); - } - this.payloadOverflowQueue.length = 0; - } - } else { - this.payloadQueue.push(payload); - } - } - - private totalPayloads() { - const queueSize = this.payloadQueue.length + this.payloadOverflowQueue.length; - return queueSize ? queueSize : this.payloadDatabase.prepare("SELECT COUNT(*) FROM payloads").pluck().get(); - } - - private async processPayloads() { - const payload = this.getNextPayload(); - if (payload) { - await this.listener(payload.workerId, payload.req, () => { - // - }); - await Utils.sleep(1); // 1ms delay allows the node to breathe - this.payloadQueue.shift(); - setImmediate(() => this.processPayloads()); - } - } - - private getNextPayload() { - const payloadsFree = this.maxPayloadQueueSize - this.payloadQueue.length; - if (payloadsFree > 0 && this.payloadQueue.length <= 1) { - const payloadsFromDB = this.payloadDatabase - .prepare(`SELECT id, payload FROM payloads LIMIT ${payloadsFree}`) - .all(); - const payloadIds: { id: number }[] = []; - for (const row of payloadsFromDB) { - this.payloadQueue.push(JSON.parse(row.payload)); - payloadIds.push({ id: row.id }); - } - const query = this.payloadDatabase.prepare("DELETE FROM payloads WHERE id = :id"); - const deleteFromDB = this.payloadDatabase.transaction((data) => { - for (const id of data) { - query.run(id); - } - }); - deleteFromDB(payloadIds); - } - if (this.payloadQueue.length <= 1 && this.payloadOverflowQueue.length > 0) { - this.payloadQueue.push(...this.payloadOverflowQueue); - this.payloadOverflowQueue.length = 0; - } - return this.payloadQueue[0]; - } -} - -// todo: bind this via ioc to avoid context issues -export const payloadProcessor = new PayloadProcessor(); diff --git a/packages/core-p2p/src/socket-server/plugins/accept-peer.ts b/packages/core-p2p/src/socket-server/plugins/accept-peer.ts new file mode 100644 index 0000000000..77e99d1bbd --- /dev/null +++ b/packages/core-p2p/src/socket-server/plugins/accept-peer.ts @@ -0,0 +1,27 @@ +import { Container, Contracts } from "@arkecosystem/core-kernel"; + +import { PeerRoute } from "../routes/peer"; + +@Container.injectable() +export class AcceptPeerPlugin { + @Container.inject(Container.Identifiers.Application) + protected readonly app!: Contracts.Kernel.Application; + + @Container.inject(Container.Identifiers.PeerProcessor) + private readonly peerProcessor!: Contracts.P2P.PeerProcessor; + + public register(server) { + const peerRoutesConfigByPath = this.app.resolve(PeerRoute).getRoutesConfigByPath(); + const peerProcessor = this.peerProcessor; + + server.ext({ + type: "onPreHandler", + async method(request, h) { + if (peerRoutesConfigByPath[request.path]) { + peerProcessor.validateAndAcceptPeer({ ip: request.info.remoteAddress } as Contracts.P2P.Peer); + } + return h.continue; + }, + }); + } +} diff --git a/packages/core-p2p/src/socket-server/plugins/validate.ts b/packages/core-p2p/src/socket-server/plugins/validate.ts new file mode 100644 index 0000000000..eeb8ed0097 --- /dev/null +++ b/packages/core-p2p/src/socket-server/plugins/validate.ts @@ -0,0 +1,30 @@ +import Boom from "@hapi/boom"; + +import { Container, Contracts } from "@arkecosystem/core-kernel"; + +import { InternalRoute } from "../routes/internal"; +import { PeerRoute } from "../routes/peer"; + +@Container.injectable() +export class ValidatePlugin { + @Container.inject(Container.Identifiers.Application) + protected readonly app!: Contracts.Kernel.Application; + + public register(server) { + const allRoutesConfigByPath = { + ...this.app.resolve(InternalRoute).getRoutesConfigByPath(), + ...this.app.resolve(PeerRoute).getRoutesConfigByPath() + }; + + server.ext({ + type: "onPostAuth", + async method(request, h) { + const result = allRoutesConfigByPath[request.path]?.validation?.validate(request.payload); + if (result && result.error) { + return Boom.badRequest("Validation failed"); + } + return h.continue; + }, + }); + } +} diff --git a/packages/core-p2p/src/socket-server/routes/internal.ts b/packages/core-p2p/src/socket-server/routes/internal.ts new file mode 100644 index 0000000000..ef403407ed --- /dev/null +++ b/packages/core-p2p/src/socket-server/routes/internal.ts @@ -0,0 +1,36 @@ +import { InternalController } from "../controllers/internal"; +import { internalSchemas } from "../schemas/internal"; +import { Route, RouteConfig } from "./route"; + +export class InternalRoute extends Route { + public getRoutesConfigByPath(): { [path: string]: RouteConfig } { + const controller = this.getController(); + return { + "/p2p/internal/emitEvent": { + id: "p2p.internal.emitEvent", + handler: controller.emitEvent, + validation: internalSchemas.emitEvent + }, + "/p2p/internal/getUnconfirmedTransactions": { + id: "p2p.internal.getUnconfirmedTransactions", + handler: controller.getUnconfirmedTransactions, + }, + "/p2p/internal/getCurrentRound": { + id: "p2p.internal.getCurrentRound", + handler: controller.getCurrentRound, + }, + "/p2p/internal/getNetworkState": { + id: "p2p.internal.getNetworkState", + handler: controller.getNetworkState, + }, + "/p2p/internal/syncBlockchain": { + id: "p2p.internal.syncBlockchain", + handler: controller.syncBlockchain, + }, + } + } + + protected getController(): InternalController { + return this.app.resolve(InternalController); + } +} diff --git a/packages/core-p2p/src/socket-server/routes/peer.ts b/packages/core-p2p/src/socket-server/routes/peer.ts new file mode 100644 index 0000000000..b0a0829ff2 --- /dev/null +++ b/packages/core-p2p/src/socket-server/routes/peer.ts @@ -0,0 +1,46 @@ +import { PeerController } from "../controllers/peer"; +import { peerSchemas } from "../schemas/peer"; +import { Route, RouteConfig } from "./route"; + +export class PeerRoute extends Route { + public getRoutesConfigByPath(): { [path: string]: RouteConfig } { + const controller = this.getController(); + return { + "/p2p/peer/getPeers": { + id: "p2p.peer.getPeers", + handler: controller.getPeers, + validation: peerSchemas.getPeers, + }, + "/p2p/peer/getBlocks": { + id: "p2p.peer.getBlocks", + handler: controller.getBlocks, + validation: peerSchemas.getBlocks, + }, + "/p2p/peer/getCommonBlocks": { + id: "p2p.peer.getCommonBlocks", + handler: controller.getCommonBlocks, + validation: peerSchemas.getCommonBlocks, + }, + "/p2p/peer/getStatus": { + id: "p2p.peer.getStatus", + handler: controller.getStatus, + validation: peerSchemas.getStatus, + }, + "/p2p/peer/postBlock": { + id: "p2p.peer.postBlock", + handler: controller.postBlock, + validation: peerSchemas.postBlock, + maxBytes: 20 * 1024 * 1024, // TODO maxBytes for each route + }, + "/p2p/peer/postTransactions": { + id: "p2p.peer.postTransactions", + handler: controller.postTransactions, + validation: peerSchemas.postTransactions, + } + } + } + + protected getController(): PeerController { + return this.app.resolve(PeerController); + } +} diff --git a/packages/core-p2p/src/socket-server/routes/route.ts b/packages/core-p2p/src/socket-server/routes/route.ts new file mode 100644 index 0000000000..0d82b61de2 --- /dev/null +++ b/packages/core-p2p/src/socket-server/routes/route.ts @@ -0,0 +1,41 @@ +import Hapi from "@hapi/hapi"; +import Joi from "@hapi/joi"; + +import { Container, Contracts } from "@arkecosystem/core-kernel"; +import { Controller } from "../controllers/controller"; + +export type RouteConfig = { + id: string; + handler: any; + validation?: Joi.Schema; + maxBytes?: number; +}; + +@Container.injectable() +export abstract class Route { + @Container.inject(Container.Identifiers.Application) + protected readonly app!: Contracts.Kernel.Application; + + public register(server: Hapi.Server): void { + const controller = this.getController(server); + server.bind(controller); + + for (const [path, config] of Object.entries(this.getRoutesConfigByPath())) { + server.route({ + method: "POST", + path, + config: { + id: config.id, + handler: config.handler, + payload: { + maxBytes: config.maxBytes, + }, + }, + }) + } + } + + public abstract getRoutesConfigByPath(): { [path: string]: RouteConfig }; + + protected abstract getController(server: Hapi.Server): Controller; +} diff --git a/packages/core-p2p/src/socket-server/schemas/internal.ts b/packages/core-p2p/src/socket-server/schemas/internal.ts new file mode 100644 index 0000000000..f064e87e2c --- /dev/null +++ b/packages/core-p2p/src/socket-server/schemas/internal.ts @@ -0,0 +1,8 @@ +import Joi from "@hapi/joi"; + +export const internalSchemas = { + emitEvent: Joi.object({ + event: Joi.string(), + body: Joi.object(), + }), +}; diff --git a/packages/core-p2p/src/socket-server/schemas/peer.ts b/packages/core-p2p/src/socket-server/schemas/peer.ts new file mode 100644 index 0000000000..35157218f3 --- /dev/null +++ b/packages/core-p2p/src/socket-server/schemas/peer.ts @@ -0,0 +1,29 @@ +import Joi from "@hapi/joi"; + +export const peerSchemas = { + getPeers: Joi.object().max(0), // empty object expected + + getBlocks: Joi.object({ + lastBlockHeight: Joi.number().integer().min(1), + blockLimit: Joi.number().integer().min(1).max(400), + headersOnly: Joi.boolean(), + serialized: Joi.boolean(), + }), + + getCommonBlocks: Joi.object({ + ids: Joi.array().min(1).max(10).items(Joi.string()), // TODO strings are block ids + }), + + getStatus: Joi.object().max(0), // empty object expected + + postBlock: Joi.object({ + block: Joi.object({ + type: "Buffer", + data: Joi.array(), // TODO better way to validate buffer ? + }), + }), + + postTransactions: Joi.object({ + transactions: Joi.array(), // TODO array of transactions, needs Joi transaction schema + }), +}; diff --git a/packages/core-p2p/src/socket-server/server.ts b/packages/core-p2p/src/socket-server/server.ts new file mode 100644 index 0000000000..31cbc3933c --- /dev/null +++ b/packages/core-p2p/src/socket-server/server.ts @@ -0,0 +1,110 @@ +import { Container, Contracts, Types } from "@arkecosystem/core-kernel"; +import { Server as HapiServer, ServerInjectOptions, ServerInjectResponse, ServerRoute } from "@hapi/hapi"; +import Nes from "@hapi/nes"; + +import { InternalRoute } from "./routes/internal"; +import { PeerRoute } from "./routes/peer"; +import { ValidatePlugin } from "./plugins/validate"; +import { AcceptPeerPlugin } from "./plugins/accept-peer"; + +// todo: review the implementation +@Container.injectable() +export class Server { + /** + * @private + * @type {Contracts.Kernel.Application} + * @memberof Server + */ + @Container.inject(Container.Identifiers.Application) + private readonly app!: Contracts.Kernel.Application; + + /** + * @private + * @type {HapiServer} + * @memberof Server + */ + private server!: HapiServer; + + /** + * @private + * @type {string} + * @memberof Server + */ + private name!: string; + + /** + * @param {string} name + * @param {Types.JsonObject} optionsServer + * @returns {Promise} + * @memberof Server + */ + public async initialize(name: string, optionsServer: Types.JsonObject): Promise { + this.name = name; + this.server = new HapiServer({ address: optionsServer.hostname, port: optionsServer.port }); + (this.server.app as any).app = this.app; + + await this.server.register(Nes); + + this.app.resolve(InternalRoute).register(this.server); + this.app.resolve(PeerRoute).register(this.server); + + this.app.resolve(ValidatePlugin).register(this.server); + this.app.resolve(AcceptPeerPlugin).register(this.server); + } + + /** + * @returns {Promise} + * @memberof Server + */ + public async boot(): Promise { + try { + await this.server.start(); + + this.app.log.info(`${this.name} P2P server started at ${this.server.info.uri}`); + } catch { + await this.app.terminate(`Failed to start ${this.name} Server!`); + } + } + + /** + * @returns {Promise} + * @memberof Server + */ + public async dispose(): Promise { + try { + await this.server.stop(); + + this.app.log.info(`${this.name} Server stopped at ${this.server.info.uri}`); + } catch { + await this.app.terminate(`Failed to stop ${this.name} Server!`); + } + } + + /** + * @param {(any|any[])} plugins + * @returns {Promise} + * @memberof Server + */ + // @todo: add proper types + public async register(plugins: any | any[]): Promise { + return this.server.register(plugins); + } + + /** + * @param {(ServerRoute | ServerRoute[])} routes + * @returns {Promise} + * @memberof Server + */ + public async route(routes: ServerRoute | ServerRoute[]): Promise { + return this.server.route(routes); + } + + /** + * @param {(string | ServerInjectOptions)} options + * @returns {Promise} + * @memberof Server + */ + public async inject(options: string | ServerInjectOptions): Promise { + return this.server.inject(options); + } +} diff --git a/packages/core-p2p/src/socket-server/utils/validate.ts b/packages/core-p2p/src/socket-server/utils/validate.ts index 30ed974d16..c5ae79cd7b 100644 --- a/packages/core-p2p/src/socket-server/utils/validate.ts +++ b/packages/core-p2p/src/socket-server/utils/validate.ts @@ -12,66 +12,3 @@ export const validate = (schema, data) => { throw error; } }; - -const objectHasMorePropertiesThan = (obj: object, maxProperties: number) => { - let propertiesCount = 0; - try { - JSON.stringify(obj, (key, value) => { - propertiesCount++; - if (propertiesCount > maxProperties) { - throw new Error("exceeded maxProperties"); - } - return value; - }); - } catch (e) { - return true; - } - - return false; -}; - -// Specific light validation for transaction, to be used in socket workers -// to perform quick validation on transaction objects received in postTransactions -// TODO rework with v3 when refactoring p2p layer -export const validateTransactionLight = (transaction: any): boolean => { - if (!transaction || typeof transaction !== "object") { - return false; - } - - // except for multipayment transactions that are capped to 128 payments currently, - // a transaction should not have more than 100 properties total - const maxMainProperties = 50; - const maxAssetProperties = 100; // arbitrary, see below - const maxMultiPayments = 128; // hardcoded as will be refactored before increasing max multipayments - if (Object.keys(transaction).length > maxMainProperties) { - return false; - } - - if (transaction.asset && typeof transaction.asset === "object") { - if (transaction.asset.payments && Array.isArray(transaction.asset.payments)) { - if (transaction.asset.payments.length > maxMultiPayments) { - return false; - } - for (const p of transaction.asset.payments) { - if (!p || typeof p !== "object" || Object.keys(p).length !== 2 || !p.recipientId || !p.amount) { - return false; - } - } - } else { - // no "payments" asset, default to counting properties and checking vs maxProperties. - // totally arbitrary as we could have transactions with more properties in asset, - // but this is temporary and will be removed in v3 when p2p layer is refactored - if (objectHasMorePropertiesThan(transaction.asset, maxAssetProperties)) { - return false; - } - } - } - - const shallowClone = { ...transaction }; - delete shallowClone.asset; // to count main properties now - if (objectHasMorePropertiesThan(shallowClone, maxMainProperties)) { - return false; - } - - return true; -}; diff --git a/packages/core-p2p/src/socket-server/versions/index.ts b/packages/core-p2p/src/socket-server/versions/index.ts deleted file mode 100644 index a50791b8d8..0000000000 --- a/packages/core-p2p/src/socket-server/versions/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import * as internal from "./internal"; -import * as peer from "./peer"; -import * as utils from "./utils"; - -export { internal, peer, utils }; diff --git a/packages/core-p2p/src/socket-server/versions/internal.ts b/packages/core-p2p/src/socket-server/versions/internal.ts deleted file mode 100644 index 8faa743d35..0000000000 --- a/packages/core-p2p/src/socket-server/versions/internal.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { Container, Contracts, Providers, Utils, Services } from "@arkecosystem/core-kernel"; -import { Crypto, Interfaces, Managers } from "@arkecosystem/crypto"; -import { process } from "ipaddr.js"; - -import { PeerService } from "../../contracts"; - -// todo: turn this into a class so that ioc can be used -// todo: review the implementation of all methods - -export const acceptNewPeer = async ({ - app, - service, - req, -}: { - app: Contracts.Kernel.Application; - service: PeerService; - req; -}): Promise => service.processor.validateAndAcceptPeer({ ip: req.data.ip } as Contracts.P2P.Peer); - -export const emitEvent = ({ app, req }: { app: Contracts.Kernel.Application; req: any }): void => { - app.get(Container.Identifiers.EventDispatcherService).dispatch( - req.data.event, - req.data.body, - ); -}; - -export const isPeerOrForger = ({ - app, - service, - req, -}: { - app: Contracts.Kernel.Application; - service: PeerService; - req; -}): { isPeerOrForger: boolean } => { - const sanitizedIp = process(req.data.ip).toString(); - const configuration = app.getTagged( - Container.Identifiers.PluginConfiguration, - "plugin", - "@arkecosystem/core-p2p", - ); - - return { - isPeerOrForger: - service.storage.hasPeer(sanitizedIp) || - Utils.isWhitelisted(configuration.getRequired("remoteAccess"), sanitizedIp), - }; -}; - -export const getUnconfirmedTransactions = async ({ - app, -}: { - app: Contracts.Kernel.Application; -}): Promise => { - const collator: Contracts.TransactionPool.Collator = app.get( - Container.Identifiers.TransactionPoolCollator, - ); - const transactionPool: Contracts.TransactionPool.Service = app.get( - Container.Identifiers.TransactionPoolService, - ); - const transactions: Interfaces.ITransaction[] = await collator.getBlockCandidateTransactions(); - - return { - poolSize: transactionPool.getPoolSize(), - transactions: transactions.map((t) => t.serialized.toString("hex")), - }; -}; - -export const getCurrentRound = async ({ - app, -}: { - app: Contracts.Kernel.Application; -}): Promise => { - const blockchain = app.get(Container.Identifiers.BlockchainService); - - const lastBlock = blockchain.getLastBlock(); - - const height = lastBlock.data.height + 1; - const roundInfo = Utils.roundCalculator.calculateRound(height); - const { maxDelegates, round } = roundInfo; - - const blockTime = Managers.configManager.getMilestone(height).blocktime; - const reward = Managers.configManager.getMilestone(height).reward; - - const delegates: Contracts.P2P.DelegateWallet[] = ((await app - .get(Container.Identifiers.TriggerService) - .call("getActiveDelegates", { roundInfo })) as Contracts.State.Wallet[]) - .map((wallet) => ({ - ...wallet, - delegate: wallet.getAttribute("delegate"), - })); - - const timestamp = Crypto.Slots.getTime(); - const blockTimestamp = Crypto.Slots.getSlotNumber(timestamp) * blockTime; - const currentForger = parseInt((timestamp / blockTime) as any) % maxDelegates; - const nextForger = (parseInt((timestamp / blockTime) as any) + 1) % maxDelegates; - - return { - current: round, - reward, - timestamp: blockTimestamp, - delegates, - currentForger: delegates[currentForger], - nextForger: delegates[nextForger], - lastBlock: lastBlock.data, - canForge: parseInt((1 + lastBlock.data.timestamp / blockTime) as any) * blockTime < timestamp - 1, - }; -}; - -export const getNetworkState = async ({ service }: { service: PeerService }): Promise => - service.networkMonitor.getNetworkState(); - -export const getRateLimitStatus = async ({ - service, - req, -}: { - service: PeerService; - req: { data: { ip: string; endpoint?: string } }; -}): Promise => { - return service.networkMonitor.getRateLimitStatus(req.data.ip, req.data.endpoint); -}; - -export const isBlockedByRateLimit = async ({ - service, - req, -}: { - service: PeerService; - req: { data: { ip: string } }; -}): Promise<{ blocked: boolean }> => { - return { - blocked: await service.networkMonitor.isBlockedByRateLimit(req.data.ip), - }; -}; - -export const syncBlockchain = ({ app }: { app: Contracts.Kernel.Application }): void => { - app.log.debug("Blockchain sync check WAKEUP requested by forger"); - - app.get(Container.Identifiers.BlockchainService).forceWakeup(); -}; - -export const getRateLimitedEndpoints = ({ service }: { service: PeerService }): string[] => { - return service.networkMonitor.getRateLimitedEndpoints(); -}; diff --git a/packages/core-p2p/src/socket-server/versions/peer.ts b/packages/core-p2p/src/socket-server/versions/peer.ts deleted file mode 100644 index 42a104b6da..0000000000 --- a/packages/core-p2p/src/socket-server/versions/peer.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { DatabaseService } from "@arkecosystem/core-database"; -import { Container, Contracts, Providers, Utils } from "@arkecosystem/core-kernel"; -import { Blocks, Crypto, Interfaces, Managers } from "@arkecosystem/crypto"; - -import { PeerService } from "../../contracts"; -import { MissingCommonBlockError } from "../../errors"; -import { isWhitelisted } from "../../utils"; -import { TooManyTransactionsError, UnchainedBlockError } from "../errors"; -import { getPeerConfig } from "../utils/get-peer-config"; -import { mapAddr } from "../utils/map-addr"; - -// todo: review the implementation of all methods - -export const getPeers = ({ service }: { service: PeerService }): Contracts.P2P.PeerBroadcast[] => { - return service.storage - .getPeers() - .map((peer) => peer.toBroadcast()) - .sort((a, b) => { - Utils.assert.defined(a.latency); - Utils.assert.defined(b.latency); - - return a.latency - b.latency; - }); -}; - -export const getCommonBlocks = async ({ - app, - req, -}: { - app: Contracts.Kernel.Application; - req: any; -}): Promise<{ - common: Interfaces.IBlockData; - lastBlockHeight: number; -}> => { - const blockchain: Contracts.Blockchain.Blockchain = app.get( - Container.Identifiers.BlockchainService, - ); - - const database: DatabaseService = app.get(Container.Identifiers.DatabaseService); - - const commonBlocks: Interfaces.IBlockData[] = await database.getCommonBlocks(req.data.ids); - - if (!commonBlocks.length) { - throw new MissingCommonBlockError(); - } - - return { - common: commonBlocks[0], - lastBlockHeight: blockchain.getLastBlock().data.height, - }; -}; - -export const getStatus = async ({ - app, -}: { - app: Contracts.Kernel.Application; -}): Promise => { - const lastBlock: Interfaces.IBlock = app - .get(Container.Identifiers.BlockchainService) - .getLastBlock(); - - return { - state: { - height: lastBlock ? lastBlock.data.height : 0, - forgingAllowed: Crypto.Slots.isForgingAllowed(), - currentSlot: Crypto.Slots.getSlotNumber(), - header: lastBlock ? lastBlock.getHeader() : {}, - }, - config: getPeerConfig(app), - }; -}; - -export const postBlock = async ({ app, req }: { app: Contracts.Kernel.Application; req: any }): Promise => { - const configuration = app.getTagged( - Container.Identifiers.PluginConfiguration, - "plugin", - "@arkecosystem/core-p2p", - ); - const blockchain: Contracts.Blockchain.Blockchain = app.get( - Container.Identifiers.BlockchainService, - ); - - const blockHex: string = (req.data.block as Buffer).toString("hex"); - - const deserializedHeader = Blocks.Deserializer.deserialize(blockHex, true); - - if (deserializedHeader.data.numberOfTransactions > Managers.configManager.getMilestone().block.maxTransactions) { - throw new TooManyTransactionsError(deserializedHeader.data); - } - - const deserialized: { - data: Interfaces.IBlockData; - transactions: Interfaces.ITransaction[]; - } = Blocks.Deserializer.deserialize(blockHex); - - const block: Interfaces.IBlockData = { - ...deserialized.data, - transactions: deserialized.transactions.map((tx) => tx.data), - }; - - const fromForger: boolean = isWhitelisted( - configuration.getOptional("remoteAccess", []), - req.headers.remoteAddress, - ); - - if (!fromForger) { - if (blockchain.pingBlock(block)) { - return; - } - - const lastDownloadedBlock: Interfaces.IBlockData = blockchain.getLastDownloadedBlock(); - - if (!Utils.isBlockChained(lastDownloadedBlock, block)) { - throw new UnchainedBlockError(lastDownloadedBlock.height, block.height); - } - } - - if (block.transactions && block.transactions.length > Managers.configManager.getMilestone().block.maxTransactions) { - throw new TooManyTransactionsError(block); - } - - app.log.info( - `Received new block at height ${block.height.toLocaleString()} with ${Utils.pluralize( - "transaction", - block.numberOfTransactions, - true, - )} from ${mapAddr(req.headers.remoteAddress)}`, - ); - - blockchain.handleIncomingBlock(block, fromForger); -}; - -export const postTransactions = async ({ - app, - req, -}: { - app: Contracts.Kernel.Application; - req: any; -}): Promise => { - const createProcessor: Contracts.TransactionPool.ProcessorFactory = app.get( - Container.Identifiers.TransactionPoolProcessorFactory, - ); - const processor: Contracts.TransactionPool.Processor = createProcessor(); - await processor.process(req.data.transactions); - return processor.accept; -}; - -export const getBlocks = async ({ - app, - req, -}: { - app: Contracts.Kernel.Application; - req: any; -}): Promise => { - const database: DatabaseService = app.get(Container.Identifiers.DatabaseService); - - const reqBlockHeight: number = +req.data.lastBlockHeight + 1; - const reqBlockLimit: number = +req.data.blockLimit || 400; - const reqHeadersOnly: boolean = !!req.data.headersOnly; - - const blocks: Contracts.Shared.DownloadBlock[] = await database.getBlocksForDownload( - reqBlockHeight, - reqBlockLimit, - reqHeadersOnly, - ); - - app.log.info( - `${mapAddr(req.headers.remoteAddress)} has downloaded ${Utils.pluralize( - "block", - blocks.length, - true, - )} from height ${reqBlockHeight.toLocaleString()}`, - ); - - return blocks; -}; diff --git a/packages/core-p2p/src/socket-server/versions/utils.ts b/packages/core-p2p/src/socket-server/versions/utils.ts deleted file mode 100644 index b31bc86ae6..0000000000 --- a/packages/core-p2p/src/socket-server/versions/utils.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Container, Contracts, Providers } from "@arkecosystem/core-kernel"; - -import { isWhitelisted } from "../../utils/is-whitelisted"; -import * as internalHandlers from "./internal"; -import * as peerHandlers from "./peer"; - -export const isAppReady = ({ app }: { app: Contracts.Kernel.Application }): { ready: boolean } => { - return { - ready: - app.isBound(Container.Identifiers.TransactionPoolService) && - app.isBound(Container.Identifiers.BlockchainService) && - app.isBound(Container.Identifiers.PeerNetworkMonitor), - }; -}; - -export const getHandlers = (): { [key: string]: string[] } => ({ - peer: Object.keys(peerHandlers), - internal: Object.keys(internalHandlers), -}); - -export const log = ({ app, req }: { app: Contracts.Kernel.Application; req: any }): void => - app.log[req.data.level](req.data.message); - -export const isForgerAuthorized = ({ - app, - req, -}: { - app: Contracts.Kernel.Application; - req: any; -}): { authorized: boolean } => { - const configuration = app.getTagged( - Container.Identifiers.PluginConfiguration, - "plugin", - "@arkecosystem/core-p2p", - ); - const authorized = isWhitelisted(configuration.getOptional("remoteAccess", []), req.data.ip); - return { authorized }; -}; - -export const getConfig = ({ app }: { app: Contracts.Kernel.Application }): Record => { - const configuration = app - .getTagged( - Container.Identifiers.PluginConfiguration, - "plugin", - "@arkecosystem/core-p2p", - ) - .all(); - - // add maxTransactionsPerRequest config from transaction pool - configuration.maxTransactionsPerRequest = app - .getTagged( - Container.Identifiers.PluginConfiguration, - "plugin", - "@arkecosystem/core-transaction-pool", - ) - .getOptional("maxTransactionsPerRequest", 40); - - return configuration; -}; diff --git a/packages/core-p2p/src/socket-server/worker.ts b/packages/core-p2p/src/socket-server/worker.ts deleted file mode 100644 index 9b8dcebbcc..0000000000 --- a/packages/core-p2p/src/socket-server/worker.ts +++ /dev/null @@ -1,376 +0,0 @@ -import Ajv from "ajv"; -import { cidr } from "ip"; -import SCWorker from "socketcluster/scworker"; - -import { RateLimiter } from "../rate-limiter"; -import { requestSchemas } from "../schemas"; -import { buildRateLimiter } from "../utils"; -import { codec } from "../utils/sc-codec"; -import { validateTransactionLight } from "./utils/validate"; - -const MINUTE_IN_MILLISECONDS = 1000 * 60; -const HOUR_IN_MILLISECONDS = MINUTE_IN_MILLISECONDS * 60; - -const ajv = new Ajv({ extendRefs: true }); - -export class Worker extends SCWorker { - private config: Record = {}; - private handlers: string[] = []; - private ipLastError: Record = {}; - private rateLimiter: RateLimiter | undefined; - private rateLimitedEndpoints: any; - - public async run() { - this.log(`Socket worker started, PID: ${process.pid}`); - - this.scServer.setCodecEngine(codec); - - await this.loadRateLimitedEndpoints(); - await this.loadConfiguration(); - - this.rateLimiter = buildRateLimiter({ - rateLimit: this.config.rateLimit, - remoteAccess: this.config.remoteAccess, - whitelist: this.config.whitelist, - }); - - // purge ipLastError every hour to free up memory - setInterval(() => (this.ipLastError = {}), HOUR_IN_MILLISECONDS); - - await this.loadHandlers(); - - // @ts-ignore - this.scServer.wsServer.on("connection", (ws, req) => { - const clients = [...Object.values(this.scServer.clients), ...Object.values(this.scServer.pendingClients)]; - const existingSockets = clients.filter( - (client) => - client.remoteAddress === req.socket.remoteAddress && client.remotePort !== req.socket.remotePort, - ); - for (const socket of existingSockets) { - socket.terminate(); - } - this.handlePayload(ws, req); - }); - this.scServer.on("connection", (socket) => this.handleConnection(socket)); - this.scServer.addMiddleware(this.scServer.MIDDLEWARE_HANDSHAKE_WS, (req, next) => - this.handleHandshake(req, next), - ); - this.scServer.addMiddleware(this.scServer.MIDDLEWARE_EMIT, (req, next) => this.handleEmit(req, next)); - } - - private async loadHandlers(): Promise { - const { data } = await this.sendToMasterAsync("p2p.utils.getHandlers"); - for (const [version, handlers] of Object.entries(data)) { - for (const handler of Object.values(handlers as object)) { - this.handlers.push(`p2p.${version}.${handler}`); - } - } - } - - private async loadConfiguration(): Promise { - const { data } = await this.sendToMasterAsync("p2p.utils.getConfig"); - this.config = data; - } - - private async loadRateLimitedEndpoints(): Promise { - const { data } = await this.sendToMasterAsync("p2p.internal.getRateLimitedEndpoints", { data: {} }); - this.rateLimitedEndpoints = (Array.isArray(data) ? data : []).reduce((object, value) => { - object[value] = true; - return object; - }, {}); - } - - private getRateLimitedEndpoints() { - return this.rateLimitedEndpoints; - } - - private handlePayload(ws, req) { - ws.removeAllListeners("ping"); - ws.removeAllListeners("pong"); - ws.prependListener("ping", () => { - this.setErrorForIpAndTerminate(ws, req); - }); - ws.prependListener("pong", () => { - this.setErrorForIpAndTerminate(ws, req); - }); - - ws.prependListener("error", (error) => { - if (error instanceof RangeError) { - this.setErrorForIpAndTerminate(ws, req); - } - }); - - const messageListeners = ws.listeners("message"); - ws.removeAllListeners("message"); - ws.prependListener("message", (message) => { - if (ws._disconnected) { - return this.setErrorForIpAndTerminate(ws, req); - } else if (message === "#2") { - const timeNow: number = new Date().getTime() / 1000; - if (ws._lastPingTime && timeNow - ws._lastPingTime < 1) { - return this.setErrorForIpAndTerminate(ws, req); - } - ws._lastPingTime = timeNow; - } else if (message.length < 10) { - // except for #2 message, we should have JSON with some required properties - // (see below) which implies that message length should be longer than 10 chars - return this.setErrorForIpAndTerminate(ws, req); - } else { - try { - const parsed = JSON.parse(message); - if (parsed.event === "#disconnect") { - ws._disconnected = true; - } else if (parsed.event === "#handshake") { - if (ws._handshake) { - return this.setErrorForIpAndTerminate(ws, req); - } - ws._handshake = true; - } else if ( - typeof parsed.event !== "string" || - typeof parsed.data !== "object" || - this.hasAdditionalProperties(parsed) || - (typeof parsed.cid !== "number" && - parsed.event === "#disconnect" && - typeof parsed.cid !== "undefined") || - !this.handlers.includes(parsed.event) - ) { - return this.setErrorForIpAndTerminate(ws, req); - } - } catch (error) { - return this.setErrorForIpAndTerminate(ws, req); - } - } - - // we call the other listeners ourselves - for (const listener of messageListeners) { - listener(message); - } - }); - } - - private hasAdditionalProperties(object): boolean { - if (Object.keys(object).filter((key) => key !== "event" && key !== "data" && key !== "cid").length) { - return true; - } - const event = object.event.split("."); - if (object.event !== "#handshake" && object.event !== "#disconnect") { - if (event.length !== 3) { - return true; - } - if (Object.keys(object.data).filter((key) => key !== "data" && key !== "headers").length) { - return true; - } - } - if (object.data.data) { - // @ts-ignore - const [_, version, handler] = event; - const schema = requestSchemas[version][handler]; - try { - if (object.event === "p2p.peer.postTransactions") { - if ( - typeof object.data.data === "object" && - object.data.data.transactions && - Array.isArray(object.data.data.transactions) && - object.data.data.transactions.length <= this.config.maxTransactionsPerRequest - ) { - for (const transaction of object.data.data.transactions) { - if (!validateTransactionLight(transaction)) { - return true; - } - } - } else { - return true; - } - } else if (schema && !ajv.validate(schema, object.data.data)) { - return true; - } - } catch { - // - } - } - if (object.data.headers) { - if ( - Object.keys(object.data.headers).filter( - (key) => key !== "version" && key !== "port" && key !== "height" && key !== "Content-Type", - ).length - ) { - return true; - } - if ( - (object.data.headers.version && typeof object.data.headers.version !== "string") || - (object.data.headers.port && typeof object.data.headers.port !== "number") || - (object.data.headers["Content-Type"] && typeof object.data.headers["Content-Type"] !== "string") || - (object.data.headers.height && typeof object.data.headers.height !== "number") - ) { - // this prevents the nesting of other objects inside these properties - return true; - } - } - return false; - } - - private setErrorForIpAndTerminate(ws, req): void { - this.ipLastError[req.socket.remoteAddress] = Date.now(); - ws.terminate(); - } - - private async handleConnection(socket): Promise { - for (const handler of this.handlers) { - // @ts-ignore - socket.on(handler, async (data, res) => { - try { - return res(undefined, await this.sendToMasterAsync(handler, data)); - } catch (e) { - return res(e); - } - }); - } - } - - private async handleHandshake(req, next): Promise { - const ip = req.socket.remoteAddress; - if (this.ipLastError[ip] && this.ipLastError[ip] > Date.now() - MINUTE_IN_MILLISECONDS) { - req.socket.destroy(); - return; - } - - const { data }: { data: { blocked: boolean } } = await this.sendToMasterAsync( - "p2p.internal.isBlockedByRateLimit", - { - data: { ip }, - }, - ); - - const isBlacklisted: boolean = (this.config.blacklist || []).includes(ip); - if (data.blocked || isBlacklisted) { - req.socket.destroy(); - return; - } - - const cidrRemoteAddress = cidr(`${ip}/24`); - const sameSubnetSockets = Object.values({ ...this.scServer.clients, ...this.scServer.pendingClients }).filter( - (client) => cidr(`${client.remoteAddress}/24`) === cidrRemoteAddress, - ); - if (sameSubnetSockets.length > this.config.maxSameSubnetPeers) { - req.socket.destroy(); - return; - } - - next(); - } - - private async handleEmit(req, next): Promise { - if (req.event.length > 128) { - req.socket.terminate(); - return; - } - const rateLimitedEndpoints = this.getRateLimitedEndpoints(); - const useLocalRateLimiter: boolean = !rateLimitedEndpoints[req.event]; - if (useLocalRateLimiter) { - if ( - this.rateLimiter && - (await this.rateLimiter.hasExceededRateLimit(req.socket.remoteAddress, req.event)) - ) { - req.socket.terminate(); - return; - } - } else { - const { data } = await this.sendToMasterAsync("p2p.internal.getRateLimitStatus", { - data: { - ip: req.socket.remoteAddress, - endpoint: req.event, - }, - }); - if (data.exceededLimitOnEndpoint) { - req.socket.terminate(); - return; - } - } - - // ensure basic format of incoming data, req.data must be as { data, headers } - if (typeof req.data !== "object" || typeof req.data.data !== "object" || typeof req.data.headers !== "object") { - req.socket.terminate(); - return; - } - - try { - const [prefix, version, handler] = req.event.split("."); - - if (prefix !== "p2p") { - req.socket.terminate(); - return; - } - - // Check that blockchain, tx-pool and p2p are ready - const isAppReady: boolean = (await this.sendToMasterAsync("p2p.utils.isAppReady")).data.ready; - - if (!isAppReady) { - next(new Error("App is not ready.")); - return; - } - - if (version === "internal") { - const { data } = await this.sendToMasterAsync("p2p.utils.isForgerAuthorized", { - data: { ip: req.socket.remoteAddress }, - }); - - if (!data.authorized) { - req.socket.terminate(); - return; - } - } else if (version === "peer") { - const requestSchema = requestSchemas.peer[handler]; - if (handler !== "postTransactions" && requestSchema && !ajv.validate(requestSchema, req.data.data)) { - req.socket.terminate(); - return; - } - - this.sendToMasterAsync("p2p.internal.acceptNewPeer", { - data: { ip: req.socket.remoteAddress }, - headers: req.data.headers, - }).catch((ex) => { - this.log(`Failed to accept new peer ${req.socket.remoteAddress}: ${ex.message}`, "debug"); - }); - } else { - req.socket.terminate(); - return; - } - - // some handlers need this remoteAddress info - // req.data is socketcluster request data, which corresponds to our own "request" object - // which is like this { endpoint, data, headers } - req.data.headers.remoteAddress = req.socket.remoteAddress; - } catch (e) { - this.log(e.message, "error"); - - req.socket.terminate(); - return; - } - - next(); - } - - private async log(message: string, level = "info"): Promise { - try { - await this.sendToMasterAsync("p2p.utils.log", { - data: { level, message }, - }); - } catch (e) { - console.error(`Error while trying to log the following message: ${message}`); - } - } - - private async sendToMasterAsync(endpoint: string, data?: Record): Promise { - return new Promise((resolve, reject) => { - this.sendToMaster( - { - ...{ endpoint }, - ...data, - }, - (err, res) => (err ? reject(err) : resolve(res)), - ); - }); - } -} - -new Worker(); diff --git a/packages/core-p2p/src/utils/index.ts b/packages/core-p2p/src/utils/index.ts index 7f43493cb0..8d021beabb 100644 --- a/packages/core-p2p/src/utils/index.ts +++ b/packages/core-p2p/src/utils/index.ts @@ -2,7 +2,5 @@ export { checkDNS } from "./check-dns"; export { checkNTP } from "./check-ntp"; export { buildRateLimiter } from "./build-rate-limiter"; export { isWhitelisted } from "./is-whitelisted"; -export { socketEmit } from "./socket"; export { validateJSON } from "./validate-json"; export { isValidVersion } from "./is-valid-version"; -export { codec } from "./sc-codec"; diff --git a/packages/core-p2p/src/utils/sc-codec.ts b/packages/core-p2p/src/utils/sc-codec.ts deleted file mode 100644 index 9eff1c7a1f..0000000000 --- a/packages/core-p2p/src/utils/sc-codec.ts +++ /dev/null @@ -1,95 +0,0 @@ -// copied from https://github.com/SocketCluster/sc-formatter -// with change - -const base64Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; -const validJSONStartRegex = /^[ \n\r\t]*[{[]/; - -const arrayBufferToBase64 = (arraybuffer) => { - const bytes = new Uint8Array(arraybuffer); - const len = bytes.length; - let base64 = ""; - - for (let i = 0; i < len; i += 3) { - base64 += base64Chars[bytes[i] >> 2]; // tslint:disable-line - base64 += base64Chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; // tslint:disable-line - base64 += base64Chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)]; // tslint:disable-line - base64 += base64Chars[bytes[i + 2] & 63]; // tslint:disable-line - } - - if (len % 3 === 2) { - base64 = base64.substring(0, base64.length - 1) + "="; - } else if (len % 3 === 1) { - base64 = base64.substring(0, base64.length - 2) + "=="; - } - - return base64; -}; - -const binaryToBase64Replacer = (key, value) => { - if (value instanceof ArrayBuffer) { - return { - base64: true, - data: arrayBufferToBase64(value), - }; - } else if (value instanceof Buffer) { - return { - base64: true, - data: value.toString("base64"), - }; - } else if (value && value.type === "Buffer" && Array.isArray(value.data)) { - // Some versions of Node.js convert Buffers to Objects before they are passed to - // the replacer function - Because of this, we need to rehydrate Buffers - // before we can convert them to base64 strings. - let rehydratedBuffer; - if (Buffer.from) { - rehydratedBuffer = Buffer.from(value.data); - } else { - rehydratedBuffer = new Buffer(value.data); - } - return { - base64: true, - data: rehydratedBuffer.toString("base64"), - }; - } - - return value; -}; - -const base64ToBinaryReplacer = (key, value) => - value && typeof value === "object" && value.base64 === true && typeof value.data === "string" - ? Buffer.from(value.data, "base64") - : value; - -// Decode the data which was transmitted over the wire to a JavaScript Object in a format which SC understands. -// See encode function below for more details. -export const decode = (input) => { - if (!input) { - return undefined; - } - // Leave ping or pong message as is - if (input === "#1" || input === "#2") { - return input; - } - const message = input.toString(); - - // Performance optimization to detect invalid JSON packet sooner. - if (!validJSONStartRegex.test(message)) { - return message; - } - - try { - return JSON.parse(message, base64ToBinaryReplacer); - } catch (err) {} // tslint:disable-line - - return message; -}; - -export const encode = (object) => { - // Leave ping or pong message as is - if (object === "#1" || object === "#2") { - return object; - } - return JSON.stringify(object, binaryToBase64Replacer); -}; - -export const codec = { encode, decode }; diff --git a/packages/core-p2p/src/utils/socket.ts b/packages/core-p2p/src/utils/socket.ts deleted file mode 100644 index 25c0859529..0000000000 --- a/packages/core-p2p/src/utils/socket.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Utils } from "@arkecosystem/core-kernel"; -import { SCClientSocket } from "socketcluster-client"; - -import { SocketErrors } from "../enums"; - -// todo: review the implementation -export const socketEmit = async ( - host: string, - socket: SCClientSocket, - event: string, - data: any, - headers: Record, - timeout?: number, -): Promise => { - const req = { - data: data || {}, - headers, - }; - - // if socket is not connected, we give it 2 seconds - for (let i = 0; i < 20 && socket.getState() !== socket.OPEN; i++) { - await Utils.sleep(100); - } - - if (socket.getState() !== socket.OPEN) { - const error = new Error(`Peer ${host} socket is not connected. State: ${socket.getState()}`); - error.name = SocketErrors.SocketNotOpen; - throw error; - } - - const socketEmitPromise = new Promise((resolve, reject) => - socket.emit(event, req, (err, val) => (err ? reject(err) : resolve(val))), - ); - - let timeoutHandle: NodeJS.Timeout | undefined = undefined; - const timeoutPromiseFn = (_, reject) => { - timeoutHandle = setTimeout(() => { - clearTimeout(timeoutHandle!); - const error = new Error(`Socket emit "${event}" : timed out (${timeout}ms)`); - error.name = SocketErrors.Timeout; - - reject(error); - }, timeout || 0); - }; - - const response = await Promise.race( - timeout ? [socketEmitPromise, new Promise(timeoutPromiseFn)] : [socketEmitPromise], - ); - - if (timeoutHandle !== undefined) { - clearTimeout(timeoutHandle); - } - - return response; -}; diff --git a/packages/core-state/src/actions/build-delegate-ranking.ts b/packages/core-state/src/actions/build-delegate-ranking.ts index 4903768c46..79ab1e1a12 100644 --- a/packages/core-state/src/actions/build-delegate-ranking.ts +++ b/packages/core-state/src/actions/build-delegate-ranking.ts @@ -1,10 +1,11 @@ import { Services } from "@arkecosystem/core-kernel"; import { ActionArguments } from "@arkecosystem/core-kernel/src/types"; + import { DposState } from "../dpos"; export class BuildDelegateRankingAction extends Services.Triggers.Action { public async execute(args: ActionArguments): Promise { - let dposState: DposState = args.dposState; + const dposState: DposState = args.dposState; return dposState.buildDelegateRanking(); }