diff --git a/package.json b/package.json index 6f36aba..ac6a774 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "del-osx", + "name": "del-sdk", "version": "1.0.0", "description": "JS SDK for links e-mail and wallet addresses", "repository": "https://github.com/bosagora/del-sdk", diff --git a/packages/client/package.json b/packages/client/package.json index 8e597f0..3980f24 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -49,12 +49,19 @@ } ], "devDependencies": { + "@ethersproject/experimental": "^5.7.0", "@size-limit/preset-small-lib": "^7.0.8", + "@types/cors": "^2.8.8", "@types/express": "^4.17.8", + "@types/node-cron": "^3.0.1", "bigint-buffer": "^1.1.5", + "cors": "^2.8.5", + "express": "^4.17.1", + "express-validator": "^6.14.0", "ganache": "^7.9.1", "glob": "^8.0.3", "husky": "^7.0.4", + "node-cron": "^3.0.0", "size-limit": "^7.0.8", "solc": "0.4.17", "tsdx": "^0.14.1", @@ -72,7 +79,8 @@ "@ethersproject/wallet": "^5.7.0", "del-osx-lib": "^1.0.6", "del-sdk-common": "^1.0.0", - "dotenv": "^16.3.1" + "dotenv": "^16.3.1", + "unfetch": "^5.0.0" }, "jest": { "testEnvironment": "../../test-environment.js", diff --git a/packages/client/src/client-common/interfaces/common.ts b/packages/client/src/client-common/interfaces/common.ts index 8fa7b58..1760361 100644 --- a/packages/client/src/client-common/interfaces/common.ts +++ b/packages/client/src/client-common/interfaces/common.ts @@ -3,3 +3,12 @@ export type SupportedNetworks = typeof SupportedNetworksArray[number]; export type NetworkDeployment = { LinkCollection: string; }; + +export type GenericRecord = Record; + +export interface IHttpConfig { + /** IPFS Cluster URL */ + url: URL; + /** Additional headers to be included with requests */ + headers?: Record; +} diff --git a/packages/client/src/client-common/interfaces/core.ts b/packages/client/src/client-common/interfaces/core.ts index 4594a9d..cdc33ff 100644 --- a/packages/client/src/client-common/interfaces/core.ts +++ b/packages/client/src/client-common/interfaces/core.ts @@ -3,6 +3,8 @@ import { Signer } from "@ethersproject/abstract-signer"; import { Contract, ContractInterface } from "@ethersproject/contracts"; import { JsonRpcProvider } from "@ethersproject/providers"; +import { GenericRecord } from "./common"; +import { UnfetchResponse } from "unfetch"; export interface IClientWeb3Core { useSigner: (signer: Signer) => void; @@ -14,6 +16,10 @@ export interface IClientWeb3Core { ensureOnline: () => Promise; attachContract: (address: string, abi: ContractInterface) => Contract & T; getLinkCollectionAddress: () => string; + isRelayUp: () => Promise; + assignValidatorEndpoint: () => Promise; + get: (path: string, data?: GenericRecord) => Promise; + post: (path: string, data?: GenericRecord) => Promise; } export interface IClientCore { diff --git a/packages/client/src/client-common/modules/web3.ts b/packages/client/src/client-common/modules/web3.ts index 826e113..2759936 100644 --- a/packages/client/src/client-common/modules/web3.ts +++ b/packages/client/src/client-common/modules/web3.ts @@ -4,7 +4,12 @@ import { JsonRpcProvider } from "@ethersproject/providers"; import { Contract, ContractInterface } from "@ethersproject/contracts"; import { Signer } from "@ethersproject/abstract-signer"; import { IClientWeb3Core } from "../interfaces/core"; -import { NoLinkCollection } from "del-sdk-common"; +import { NoLinkCollection, NoProviderError, NoSignerError } from "del-sdk-common"; +import { GenericRecord, IHttpConfig } from "../interfaces/common"; +import { LinkCollection__factory } from "del-osx-lib"; +import { NoValidator } from "../../utils/errors"; +import { Network } from "../../utils/network"; +import { UnfetchResponse } from "unfetch"; const linkCollectionAddressMap = new Map(); const providersMap = new Map(); @@ -28,6 +33,10 @@ export class Web3Module implements IClientWeb3Core { linkCollectionAddressMap.set(this, context.linkCollectionAddress); } + this.config = { + url: new URL("http://localhost"), + headers: {}, + }; Object.freeze(Web3Module.prototype); Object.freeze(this); } @@ -155,4 +164,39 @@ export class Web3Module implements IClientWeb3Core { } return this.linkCollectionAddress; } + public config: IHttpConfig; + + public async assignValidatorEndpoint(): Promise { + const signer = this.getConnectedSigner(); + if (!signer) { + throw new NoSignerError(); + } else if (!signer.provider) { + throw new NoProviderError(); + } + + const contract = LinkCollection__factory.connect(this.getLinkCollectionAddress(), signer); + const validators = await contract.getValidators(); + if (validators.length === 0) { + throw new NoValidator(); + } + const idx = Math.floor(Math.random() * validators.length); + this.config.url = new URL(validators[idx].endpoint); + } + + public async isRelayUp(): Promise { + try { + const res = await this.get("/"); + return (res.status === 200 && (await res.json())) === "OK"; + } catch { + return false; + } + } + + public async get(path: string, data?: GenericRecord): Promise { + return Network.get(this.config, path, data); + } + + public async post(path: string, data?: GenericRecord): Promise { + return Network.post(this.config, path, data); + } } diff --git a/packages/client/src/interfaces.ts b/packages/client/src/interfaces.ts index dab63c7..5334061 100644 --- a/packages/client/src/interfaces.ts +++ b/packages/client/src/interfaces.ts @@ -5,8 +5,13 @@ import { BigNumber } from "ethers"; export interface IClientMethods extends IClientCore { addRequest: (email: string) => AsyncGenerator; toAddress: (email: string) => Promise; - toEmail: (wallet: string) => Promise; - nonceOf: (wallet: string) => Promise; + toEmail: (address: string) => Promise; + nonceOf: (address: string) => Promise; + getValidators: () => Promise; + isRelayUp: () => Promise; + assignValidatorEndpoint: () => Promise; + register: (email: string) => AsyncGenerator; + getRegisterStatus: (id: string) => Promise; } export interface IClient { @@ -29,6 +34,13 @@ export type NonceOfParams = { wallet: string; }; +export type ValidatorInfoValue = { + address: string; + index: number; + endpoint: string; + status: number; +}; + export type AddRequestValue = | { key: AddRequestSteps.ADDING; txHash: string } | { @@ -36,10 +48,24 @@ export type AddRequestValue = email: string; id: string; emailHash: string; - wallet: string; + address: string; }; export enum AddRequestSteps { ADDING = "adding", DONE = "done", } + +export type RegisterValue = + | { key: RegisterSteps.DOING; requestId: string; email: string; address: string } + | { + key: RegisterSteps.DONE; + requestId: string; + email: string; + address: string; + }; + +export enum RegisterSteps { + DOING = "doing", + DONE = "done", +} diff --git a/packages/client/src/internal/client/methods.ts b/packages/client/src/internal/client/methods.ts index 1a7e1a8..38fe061 100644 --- a/packages/client/src/internal/client/methods.ts +++ b/packages/client/src/internal/client/methods.ts @@ -1,12 +1,29 @@ import { LinkCollection__factory } from "del-osx-lib"; import { NoProviderError, NoSignerError } from "del-sdk-common"; -import { AddRequestSteps, AddRequestValue, IClientMethods } from "../../interfaces"; +import { + AddRequestSteps, + AddRequestValue, + IClientMethods, + RegisterSteps, + RegisterValue, + ValidatorInfoValue, +} from "../../interfaces"; import { ClientCore, Context } from "../../client-common"; import { ContractUtils } from "../../utils/ContractUtils"; import { BigNumber } from "ethers"; import { Contract } from "@ethersproject/contracts"; +import { + AlreadyRegisteredAddress, + AlreadyRegisteredEmail, + FailedParameterValidation, + NotValidSignature, + ServerError, + UnknownError, +} from "../../utils/errors"; + +import { handleNetworkError } from "../../utils/network/ErrorTypes"; /** * Methods module the SDK Generic Client @@ -14,15 +31,23 @@ import { Contract } from "@ethersproject/contracts"; export class ClientMethods extends ClientCore implements IClientMethods { constructor(context: Context) { super(context); + Object.freeze(ClientMethods.prototype); Object.freeze(this); } + public async assignValidatorEndpoint(): Promise { + await this.web3.assignValidatorEndpoint(); + } + + public async isRelayUp(): Promise { + return await this.web3.isRelayUp(); + } /** * Add a request * * @param {email} email - * @return {*} {AsyncGenerator} + * @return {*} {AsyncGenerator} * @memberof ClientMethods */ public async *addRequest(email: string): AsyncGenerator { @@ -65,11 +90,18 @@ export class ClientMethods extends ClientCore implements IClientMethods { id: events[0].args[0], email: email, emailHash: events[0].args[1], - wallet: events[0].args[2], + address: events[0].args[2], }; } - public async toAddress(email: string): Promise { + /** + * Register email & address + * + * @param {email} email + * @return {*} {AsyncGenerator} + * @memberof ClientMethods + */ + public async *register(email: string): AsyncGenerator { const signer = this.web3.getConnectedSigner(); if (!signer) { throw new NoSignerError(); @@ -78,32 +110,90 @@ export class ClientMethods extends ClientCore implements IClientMethods { } const contract = LinkCollection__factory.connect(this.web3.getLinkCollectionAddress(), signer); + const address = await signer.getAddress(); + const nonce = await contract.nonceOf(address); + const signature = await ContractUtils.signRequestData(signer, email, nonce); + const res = await this.web3.post("request", { + email, + address, + signature, + }); + + if (!res.ok) { + throw handleNetworkError(res); + } + + const response = await res.json(); + if (response.code === 200) { + } else if (response.code === 400) { + throw new FailedParameterValidation(); + } else if (response.code === 401) { + throw new NotValidSignature(); + } else if (response.code === 402) { + throw new AlreadyRegisteredEmail(); + } else if (response.code === 403) { + throw new AlreadyRegisteredAddress(); + } else if (response.code === 500) { + throw new ServerError(); + } else { + throw new UnknownError(); + } + + yield { + key: RegisterSteps.DOING, + requestId: response.data.requestId, + email, + address, + }; + + const start = ContractUtils.getTimeStamp(); + let done = false; + while (!done) { + const status = await this.getRegisterStatus(response.data.requestId); + if (status !== 0 || ContractUtils.getTimeStamp() - start > 60) { + done = true; + } else { + await ContractUtils.delay(3000); + } + } + + yield { + key: RegisterSteps.DONE, + requestId: response.data.requestId, + email, + address, + }; + } + + public async toAddress(email: string): Promise { + const provider = this.web3.getProvider(); + if (!provider) { + throw new NoProviderError(); + } + + const contract = LinkCollection__factory.connect(this.web3.getLinkCollectionAddress(), provider); return await contract.toAddress(email); } - public async toEmail(wallet: string): Promise { - const signer = this.web3.getConnectedSigner(); - if (!signer) { - throw new NoSignerError(); - } else if (!signer.provider) { + public async toEmail(address: string): Promise { + const provider = this.web3.getProvider(); + if (!provider) { throw new NoProviderError(); } - const contract = LinkCollection__factory.connect(this.web3.getLinkCollectionAddress(), signer); + const contract = LinkCollection__factory.connect(this.web3.getLinkCollectionAddress(), provider); - return await contract.toEmail(wallet); + return await contract.toEmail(address); } public async nonceOf(wallet: string): Promise { - const signer = this.web3.getConnectedSigner(); - if (!signer) { - throw new NoSignerError(); - } else if (!signer.provider) { + const provider = this.web3.getProvider(); + if (!provider) { throw new NoProviderError(); } - const contract = LinkCollection__factory.connect(this.web3.getLinkCollectionAddress(), signer); + const contract = LinkCollection__factory.connect(this.web3.getLinkCollectionAddress(), provider); return await contract.nonceOf(wallet); } @@ -120,4 +210,33 @@ export class ClientMethods extends ClientCore implements IClientMethods { if (await contract.isAvailable(id)) return id; } } + + public async getValidators(): Promise { + const provider = this.web3.getProvider(); + if (!provider) { + throw new NoProviderError(); + } + + const contract = LinkCollection__factory.connect(this.web3.getLinkCollectionAddress(), provider); + const validators = await contract.getValidators(); + return validators.map(m => { + return { + address: m.validator, + index: m.index.toNumber(), + endpoint: m.endpoint, + status: m.status, + }; + }); + } + + public async getRegisterStatus(id: string): Promise { + const provider = this.web3.getProvider(); + if (!provider) { + throw new NoProviderError(); + } + + const contract = LinkCollection__factory.connect(this.web3.getLinkCollectionAddress(), provider); + const res = await contract.getRequestItem(id); + return res.status; + } } diff --git a/packages/client/src/utils/ContractUtils.ts b/packages/client/src/utils/ContractUtils.ts index cae45bf..f286f4e 100644 --- a/packages/client/src/utils/ContractUtils.ts +++ b/packages/client/src/utils/ContractUtils.ts @@ -50,6 +50,16 @@ export class ContractUtils { return "0x" + data.toString("hex"); } + public static getTimeStamp(): number { + return Math.floor(new Date().getTime() / 1000); + } + + public static delay(interval: number): Promise { + return new Promise((resolve, _) => { + setTimeout(resolve, interval); + }); + } + public static getRequestId(emailHash: string, address: string, nonce: BigNumberish): string { const encodedResult = ethers.utils.defaultAbiCoder.encode( ["bytes32", "address", "uint256", "bytes32"], diff --git a/packages/client/src/utils/errors.ts b/packages/client/src/utils/errors.ts new file mode 100644 index 0000000..bcde885 --- /dev/null +++ b/packages/client/src/utils/errors.ts @@ -0,0 +1,104 @@ +import { UnfetchResponse } from "unfetch"; + +export class InvalidEmailParamError extends Error { + constructor() { + super("The param does not email"); + } +} + +export class MismatchApproveAddressError extends Error { + constructor() { + super("Customer and approver mismatch"); + } +} + +export class UnregisteredEmailError extends Error { + constructor() { + super("Unregistered email error"); + } +} +export class InsufficientBalanceError extends Error { + constructor() { + super("Insufficient balance error"); + } +} + +export class NoHttpModuleError extends Error { + constructor() { + super("A Http Module is needed"); + } +} +export class ClientError extends Error { + public response: UnfetchResponse; + constructor(res: UnfetchResponse) { + super(res.statusText); + this.name = "ClientError"; + this.response = res; + } +} + +export class InvalidResponseError extends ClientError { + constructor(res: UnfetchResponse) { + super(res); + this.message = "Invalid response"; + } +} +export class MissingBodyeError extends ClientError { + constructor(res: UnfetchResponse) { + super(res); + this.message = "Missing response body"; + } +} +export class BodyParseError extends ClientError { + constructor(res: UnfetchResponse) { + super(res); + this.message = "Error parsing body"; + } +} + +export class NoValidator extends Error { + constructor() { + super("No Validators"); + } +} + +export class FailedParameterValidation extends Error { + constructor() { + super("Parameter validation failed"); + } +} + +export class NotValidSignature extends Error { + constructor() { + super("Signature is not valid"); + } +} + +export class AlreadyRegisteredEmail extends Error { + constructor() { + super("Email is already registered"); + } +} + +export class AlreadyRegisteredAddress extends Error { + constructor() { + super("Address is already registered"); + } +} + +export class ServerError extends Error { + constructor() { + super("Failed request"); + } +} + +export class UnknownError extends Error { + constructor() { + super("Unknown error occurred"); + } +} +export class EVMError extends Error { + constructor() { + super("Error in EVM"); + } +} diff --git a/packages/client/src/utils/network.ts b/packages/client/src/utils/network.ts new file mode 100644 index 0000000..f1fd1a1 --- /dev/null +++ b/packages/client/src/utils/network.ts @@ -0,0 +1,38 @@ +import fetch, { UnfetchResponse } from "unfetch"; +import { GenericRecord, IHttpConfig } from "../client-common/interfaces/common"; + +export namespace Network { + /** + * Performs a request and returns a JSON object with the response + */ + + export async function get(config: IHttpConfig, path: string, data?: GenericRecord): Promise { + const { url, headers } = config; + const endpoint: URL = new URL(path, url); + for (const [key, value] of Object.entries(data ?? {})) { + if (value != null) { + endpoint.searchParams.set(key, String(value)); + } + } + const response: UnfetchResponse = await fetch(endpoint.href, { + method: "GET", + headers, + }); + return response; + } + + export async function post(config: IHttpConfig, path: string, data?: any) { + const { url, headers } = config; + const endpoint: URL = new URL(path, url); + const response: UnfetchResponse = await fetch(endpoint.href, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: JSON.stringify(data), + }); + + return response; + } +} diff --git a/packages/client/src/utils/network/ErrorTypes.ts b/packages/client/src/utils/network/ErrorTypes.ts new file mode 100644 index 0000000..655dad1 --- /dev/null +++ b/packages/client/src/utils/network/ErrorTypes.ts @@ -0,0 +1,92 @@ +/** + * Contains definition for error types + * + * Copyright: + * Copyright (c) 2022 BOSAGORA Foundation All rights reserved. + * + * License: + * MIT License. See LICENSE for details. + */ + +/** + * The class used when a network error occurs + */ +export class NetworkError extends Error { + /** + * The status code + */ + public status: number; + + /** + * The status text + */ + public statusText: string; + + /** + * Constructor + * @param status The status code + * @param statusText The status text + */ + constructor(status: number, statusText: string) { + super(statusText); + this.name = "NetworkError"; + this.status = status; + this.statusText = statusText; + } +} + +/** + * When status code is 404 + */ +export class NotFoundError extends NetworkError { + /** + * Constructor + * @param status The status code + * @param statusText The status text + */ + constructor(status: number, statusText: string) { + super(status, statusText); + this.name = "NotFoundError"; + } +} + +/** + * When status code is 400 + */ +export class BadRequestError extends NetworkError { + /** + * Constructor + * @param status The status code + * @param statusText The status text + */ + constructor(status: number, statusText: string) { + super(status, statusText); + this.name = "BadRequestError"; + } +} + +/** + * It is a function that handles errors that occur during communication + * with a server for easy use. + * @param error This is why the error occurred + * @returns The instance of Error + */ +export function handleNetworkError(error: any): Error { + if ( + error.response !== undefined && + error.response.status !== undefined && + error.response.statusText !== undefined + ) { + switch (error.response.status) { + case 400: + return new BadRequestError(error.response.status, error.response.statusText); + case 404: + return new NotFoundError(error.response.status, error.response.statusText); + default: + return new NetworkError(error.response.status, error.response.statusText); + } + } else { + if (error.message !== undefined) return new Error(error.message); + else return new Error("An unknown error has occurred."); + } +} diff --git a/packages/client/test/helper/FakerValidator.ts b/packages/client/test/helper/FakerValidator.ts new file mode 100644 index 0000000..0dd57c2 --- /dev/null +++ b/packages/client/test/helper/FakerValidator.ts @@ -0,0 +1,458 @@ +import { ContractUtils } from "../../src"; + +import * as cron from "node-cron"; +import * as bodyParser from "body-parser"; +// @ts-ignore +import cors from "cors"; +import * as http from "http"; +// @ts-ignore +import e, * as express from "express"; +import { body, validationResult } from "express-validator"; + +import { LinkCollection, LinkCollection__factory } from "del-osx-lib"; + +import { AddressZero, HashZero } from "@ethersproject/constants"; +import { NonceManager } from "@ethersproject/experimental"; +import { BigNumberish, Signer } from "ethers"; + +import { GasPriceManager } from "./GasPriceManager"; +import { GanacheServer } from "./GanacheServer"; +import { Deployment } from "./ContractDeployer"; + +export enum JobType { + REGISTER, + VOTE1, + VOTE2, + VOTE3, + COUNT, +} +export interface IJob { + type: JobType; + requestId: string; + registerData?: { + emailHash: string; + address: string; + signature: string; + }; +} + +export class FakerValidator { + public static INIT_WAITING_SECONDS: number = 2; + public static INTERVAL_SECONDS: number = 12; + protected _app: express.Application; + protected _server: http.Server | null = null; + protected _deployment: Deployment; + private readonly port: number; + private readonly _accounts: Signer[]; + private readonly _worker: Worker; + + private _jobList: IJob[] = []; + + constructor(port: number | string, deployment: Deployment) { + if (typeof port === "string") this.port = parseInt(port, 10); + else this.port = port; + + this._app = e(); + this._deployment = deployment; + this._accounts = GanacheServer.accounts(); + this._worker = new Worker("*/1 * * * * *", this); + } + + private get validator1(): Signer { + return new NonceManager(new GasPriceManager(this._accounts[2])); + } + + private get validator2(): Signer { + return new NonceManager(new GasPriceManager(this._accounts[3])); + } + + private get validator3(): Signer { + return new NonceManager(new GasPriceManager(this._accounts[4])); + } + + public start(): Promise { + this._app.use(bodyParser.urlencoded({ extended: false })); + this._app.use(bodyParser.json()); + this._app.use( + cors({ + allowedHeaders: "*", + credentials: true, + methods: "GET, POST", + origin: "*", + preflightContinue: false, + }) + ); + + this._app.get("/", [], this.getHealthStatus.bind(this)); + this._app.post( + "/request", + [ + body("email") + .exists() + .trim() + .isEmail(), + body("address") + .exists() + .trim() + .isEthereumAddress(), + body("signature") + .exists() + .trim() + .matches(/^(0x)[0-9a-f]{130}$/i), + ], + this.postRequest.bind(this) + ); + + // Listen on provided this.port on this.address. + return new Promise((resolve, reject) => { + // Create HTTP server. + this._server = http.createServer(this._app); + this._server.on("error", reject); + this._server.listen(this.port, async () => { + await this.onStart(); + await this._worker.start(); + resolve(); + }); + }); + } + + private async onStart() { + await this.getContract() + .connect(this.validator1) + .updateEndpoint(`http://127.0.0.1:${this.port}`); + await this.getContract() + .connect(this.validator2) + .updateEndpoint(`http://127.0.0.1:${this.port}`); + await this.getContract() + .connect(this.validator3) + .updateEndpoint(`http://127.0.0.1:${this.port}`); + } + + public stop(): Promise { + return new Promise(async (resolve, reject) => { + await this._worker.stop(); + await this._worker.waitForStop(); + if (this._server != null) { + this._server.close((err?) => { + if (err) reject(err); + else resolve(); + }); + } else resolve(); + }); + } + + private makeResponseData(code: number, data: any, error?: any): any { + return { + code, + data, + error, + }; + } + + private getContract(): LinkCollection { + const contract = LinkCollection__factory.connect(this._deployment.linkCollection.address, this.validator1); + return contract; + } + + private async getRequestId(emailHash: string, address: string, nonce: BigNumberish): Promise { + while (true) { + const id = ContractUtils.getRequestId(emailHash, address, nonce); + if (await this.getContract().isAvailable(id)) return id; + } + } + + private async getHealthStatus(_: express.Request, res: express.Response) { + return res.status(200).json("OK"); + } + + private async postRequest(req: express.Request, res: express.Response) { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.json( + this.makeResponseData(400, undefined, { + message: "Failed to check the validity of parameters.", + validation: errors.array(), + }) + ); + } + + try { + const email: string = String(req.body.email).trim(); // 이메일 해시 + const address: string = String(req.body.address).trim(); // 주소 + const signature: string = String(req.body.signature).trim(); // 서명 + const nonce = await (await this.getContract()).nonceOf(address); + const emailHash = ContractUtils.sha256String(email); + if (!ContractUtils.verifyRequestData(address, email, nonce, signature)) { + return res.json( + this.makeResponseData(401, undefined, { + message: "The signature value entered is not valid.", + }) + ); + } + + const emailToAddress: string = await this.getContract().toAddress(emailHash); + if (emailToAddress !== AddressZero) { + return res.json( + this.makeResponseData(402, undefined, { + message: "The email is already registered.", + }) + ); + } + + const addressToEmail: string = await this.getContract().toEmail(address); + if (addressToEmail !== HashZero) { + return res.json( + this.makeResponseData(403, undefined, { + message: "The address is already registered.", + }) + ); + } + + const requestId = await this.getRequestId(emailHash, address, nonce); + this.addJob({ + type: JobType.REGISTER, + requestId, + registerData: { + emailHash, + address, + signature, + }, + }); + + return res.json( + this.makeResponseData(200, { + requestId, + }) + ); + } catch (error) { + const message = + error instanceof Error && error.message !== undefined ? error.message : "Failed save request"; + // console.error(message); + return res.json( + this.makeResponseData(500, undefined, { + message, + }) + ); + } + } + + private async addRequest(requestId: string, emailHash: string, address: string, signature: string) { + try { + await this.getContract() + .connect(this.validator1) + .addRequest(requestId, emailHash, address, signature); + } catch (e) { + const message = + e instanceof Error && e.message !== undefined + ? e.message + : "Error when saving a request to the contract."; + console.error(message); + } + } + + private async voteAgreement(signer: Signer, requestId: string) { + try { + await (await this.getContract()).connect(signer).voteRequest(requestId, 1); + } catch (e) { + const message = e instanceof Error && e.message !== undefined ? e.message : "Error when calling contract"; + console.error(message); + } + } + + private async countVote(requestId: string) { + try { + await (await this.getContract()).connect(this.validator1).countVote(requestId); + } catch (e) { + const message = e instanceof Error && e.message !== undefined ? e.message : "Error when calling contract"; + console.error(message); + } + } + public async onWork() { + const job = this.getJob(); + if (job !== undefined) { + switch (job.type) { + case JobType.REGISTER: + console.info(`JobType.REGISTER ${job.requestId}`); + if (job.registerData !== undefined) { + await this.addRequest( + job.requestId, + job.registerData.emailHash, + job.registerData.address, + job.registerData.signature + ); + } + + this.addJob({ + type: JobType.VOTE1, + requestId: job.requestId, + }); + break; + + case JobType.VOTE1: + console.info(`JobType.VOTE1 ${job.requestId}`); + await this.voteAgreement(this.validator1, job.requestId); + + this.addJob({ + type: JobType.VOTE2, + requestId: job.requestId, + }); + break; + + case JobType.VOTE2: + console.info(`JobType.VOTE2 ${job.requestId}`); + await this.voteAgreement(this.validator2, job.requestId); + + this.addJob({ + type: JobType.VOTE3, + requestId: job.requestId, + }); + break; + + case JobType.VOTE3: + console.info(`JobType.VOTE3 ${job.requestId}`); + await this.voteAgreement(this.validator3, job.requestId); + + this.addJob({ + type: JobType.COUNT, + requestId: job.requestId, + }); + break; + + case JobType.COUNT: + const res = await (await this.getContract()).canCountVote(job.requestId); + if (res === 1) { + console.info(`JobType.COUNT, Counting is possible. ${job.requestId}`); + await this.countVote(job.requestId); + } else if (res === 2) { + console.info(`JobType.COUNT, Counting is impossible. ${job.requestId}`); + this.addJob({ + type: JobType.COUNT, + requestId: job.requestId, + }); + } else { + console.info(`JobType.COUNT, Counting has already been completed. ${job.requestId}`); + } + break; + } + } + } + + private addJob(job: IJob) { + this._jobList.push(job); + } + + private getJob(): IJob | undefined { + return this._jobList.shift(); + } +} + +export enum WorkerState { + NONE = 0, + STARTING = 2, + RUNNING = 3, + STOPPING = 4, + STOPPED = 5, +} + +export class Worker { + protected task: cron.ScheduledTask | null = null; + private readonly _validator: FakerValidator; + + protected state: WorkerState; + + protected expression: string; + + private is_working: boolean = false; + + constructor(expression: string, validator: FakerValidator) { + this._validator = validator; + this.expression = expression; + this.state = WorkerState.NONE; + } + + public async start() { + this.state = WorkerState.STARTING; + this.is_working = false; + this.task = cron.schedule(this.expression, this.workTask.bind(this)); + this.state = WorkerState.RUNNING; + await this.onStart(); + } + + public async onStart() { + // + } + + public async stop() { + this.state = WorkerState.STOPPING; + + if (!this.is_working) { + this.state = WorkerState.STOPPED; + } + + await this.onStop(); + } + + public async onStop() { + // + } + + private stopTask() { + if (this.task !== null) { + this.task.stop(); + this.task = null; + } + } + + public waitForStop(timeout: number = 60000): Promise { + return new Promise(resolve => { + const start = Math.floor(new Date().getTime() / 1000); + const wait = () => { + if (this.state === WorkerState.STOPPED) { + this.stopTask(); + resolve(true); + } else { + const now = Math.floor(new Date().getTime() / 1000); + if (now - start < timeout) setTimeout(wait, 10); + else { + this.stopTask(); + resolve(false); + } + } + }; + wait(); + }); + } + + public isRunning(): boolean { + return this.task !== null; + } + + public isWorking(): boolean { + return this.is_working; + } + + private async workTask() { + if (this.state === WorkerState.STOPPED) return; + if (this.is_working) return; + + this.is_working = true; + try { + await this.work(); + } catch (error) { + console.error({ + validatorIndex: "none", + method: "Worker.workTask()", + message: `Failed to execute a scheduler: ${error}`, + }); + } + this.is_working = false; + + if (this.state === WorkerState.STOPPING) { + this.state = WorkerState.STOPPED; + } + } + + protected async work() { + await this._validator.onWork(); + } +} diff --git a/packages/client/test/helper/GanacheServer.ts b/packages/client/test/helper/GanacheServer.ts index f151898..a30ace6 100644 --- a/packages/client/test/helper/GanacheServer.ts +++ b/packages/client/test/helper/GanacheServer.ts @@ -124,7 +124,10 @@ export class GanacheServer { } public static createTestProvider(): JsonRpcProvider { - return new JsonRpcProvider(`http://localhost:${GanacheServer.PORT}`, GanacheServer.CHAIN_ID); + return new JsonRpcProvider(`http://localhost:${GanacheServer.PORT}`, { + chainId: GanacheServer.CHAIN_ID, + name: "bosagora_devnet", + }); } public static setTestProvider(provider: JsonRpcProvider) { diff --git a/packages/client/test/helper/Utils.ts b/packages/client/test/helper/Utils.ts index 4100df8..05e69bb 100644 --- a/packages/client/test/helper/Utils.ts +++ b/packages/client/test/helper/Utils.ts @@ -38,12 +38,6 @@ export class Utils { public static isNegative(value: string): boolean { return /^-?[0-9]\d*(\.\d+)?$/.test(value); } - - public static delay(interval: number): Promise { - return new Promise((resolve, reject) => { - setTimeout(resolve, interval); - }); - } } /** diff --git a/packages/client/test/methods.test.ts b/packages/client/test/methods.test.ts index 3f45ff2..858dd5c 100644 --- a/packages/client/test/methods.test.ts +++ b/packages/client/test/methods.test.ts @@ -1,14 +1,17 @@ import { Server } from "ganache"; import { GanacheServer } from "./helper/GanacheServer"; import { contextParamsLocalChain } from "./helper/constants"; +import { FakerValidator } from "./helper/FakerValidator"; import { Client, Context, ContractUtils } from "../src"; import { AddRequestSteps } from "../src/interfaces"; import { BigNumber } from "ethers"; import { ContractDeployer, Deployment } from "./helper/ContractDeployer"; +import { RegisterSteps } from "../src/interfaces"; describe("SDK Client", () => { let deployment: Deployment; - const [, , validator1, validator2, , user1] = GanacheServer.accounts(); + const [, , validator1, validator2, , user1, user2] = GanacheServer.accounts(); + let fakerValidator: FakerValidator; describe("SDK Client", () => { let server: Server; @@ -18,18 +21,22 @@ describe("SDK Client", () => { deployment = await ContractDeployer.deploy(); - GanacheServer.setTestWeb3Signer(user1); + fakerValidator = new FakerValidator(7080, deployment); + await fakerValidator.start(); }); afterAll(async () => { await server.close(); + await fakerValidator.stop(); }); - describe("Method Check", () => { + describe("Method Check - Not Use FakerValidator", () => { let client: Client; beforeAll(async () => { + GanacheServer.setTestWeb3Signer(user1); const context = new Context(contextParamsLocalChain); client = new Client(context); + await client.methods.assignValidatorEndpoint(); }); const userEmail = "a@example.com"; @@ -46,10 +53,10 @@ describe("SDK Client", () => { expect(step.id).toMatch(/^0x[A-Fa-f0-9]{64}$/i); expect(step.email).toEqual(userEmail); expect(step.emailHash).toEqual(ContractUtils.sha256String(userEmail)); - expect(step.wallet).toEqual(await user1.getAddress()); + expect(step.address).toEqual(await user1.getAddress()); requestId = step.id; emailHash = step.emailHash; - address = step.wallet; + address = step.address; break; default: throw new Error("Unexpected step: " + JSON.stringify(step, null, 2)); @@ -71,5 +78,51 @@ describe("SDK Client", () => { await expect(await client.methods.toEmail(address)).toEqual(emailHash); }); }); + + describe("Method Check - Use FakerValidator", () => { + let client: Client; + beforeAll(async () => { + GanacheServer.setTestWeb3Signer(user2); + const context = new Context(contextParamsLocalChain); + client = new Client(context); + await client.methods.assignValidatorEndpoint(); + }); + + it("Server Health Checking", async () => { + const isUp = await client.methods.isRelayUp(); + expect(isUp).toEqual(true); + }); + + const userEmail = "b@example.com"; + it("register", async () => { + for await (const step of client.methods.register(userEmail)) { + switch (step.key) { + case RegisterSteps.DOING: + expect(step.requestId).toMatch(/^0x[A-Fa-f0-9]{64}$/i); + expect(step.email).toEqual(userEmail); + expect(step.address).toEqual(await user2.getAddress()); + break; + case RegisterSteps.DONE: + expect(step.requestId).toMatch(/^0x[A-Fa-f0-9]{64}$/i); + expect(step.email).toEqual(userEmail); + expect(step.address).toEqual(await user2.getAddress()); + break; + default: + throw new Error("Unexpected step: " + JSON.stringify(step, null, 2)); + } + } + }); + + it("Wait", async () => { + await ContractUtils.delay(3000); + }); + + it("Check", async () => { + const emailHash = ContractUtils.sha256String(userEmail); + const address = await user2.getAddress(); + await expect(await client.methods.toAddress(emailHash)).toEqual(address); + await expect(await client.methods.toEmail(address)).toEqual(emailHash); + }); + }); }); }); diff --git a/yarn.lock b/yarn.lock index 62c680e..57dfb53 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1123,6 +1123,15 @@ "@ethersproject/properties" "^5.7.0" "@ethersproject/transactions" "^5.7.0" +"@ethersproject/experimental@^5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/experimental/-/experimental-5.7.0.tgz#9759639434d37beaedfd8acab6f3af7db246b92d" + integrity sha512-DWvhuw7Dg8JPyhMbh/CNYOwsTLjXRx/HGkacIL5rBocG8jJC0kmixwoK/J3YblO4vtcyBLMa+sV74RJZK2iyHg== + dependencies: + "@ethersproject/web" "^5.7.0" + ethers "^5.7.0" + scrypt-js "3.0.1" + "@ethersproject/hash@5.7.0", "@ethersproject/hash@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/hash/-/hash-5.7.0.tgz#eb7aca84a588508369562e16e514b539ba5240a7" @@ -1832,6 +1841,13 @@ dependencies: "@types/node" "*" +"@types/cors@^2.8.8": + version "2.8.14" + resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.14.tgz#94eeb1c95eda6a8ab54870a3bf88854512f43a92" + integrity sha512-RXHUvNWYICtbP6s18PnOCaqToK8y14DnLd75c6HfyKf228dxy7pHNOQkxPtvXKp/hINFMDjbYzsj63nnpPMSRQ== + dependencies: + "@types/node" "*" + "@types/eslint-visitor-keys@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d" @@ -1944,6 +1960,11 @@ resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw== +"@types/node-cron@^3.0.1": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@types/node-cron/-/node-cron-3.0.8.tgz#c4d774b86bf8250d1e9046e08b17875c21ae64eb" + integrity sha512-+z5VrCvLwiJUohbRSgHdyZnHzAaLuD/E2bBANw+NQ1l05Crj8dIxb/kKK+OEqRitV2Wr/LYLuEBenGDsHZVV5Q== + "@types/node@*": version "20.6.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.6.0.tgz#9d7daa855d33d4efec8aea88cd66db1c2f0ebe16" @@ -3258,7 +3279,7 @@ core-util-is@1.0.2: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ== -cors@^2.8.1: +cors@^2.8.1, cors@^2.8.5: version "2.8.5" resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== @@ -4366,7 +4387,15 @@ expect@^25.5.0: jest-message-util "^25.5.0" jest-regex-util "^25.2.6" -express@^4.14.0: +express-validator@^6.14.0: + version "6.15.0" + resolved "https://registry.yarnpkg.com/express-validator/-/express-validator-6.15.0.tgz#5e4601428960b0d66f5f4ae09cb32ed2077374a4" + integrity sha512-r05VYoBL3i2pswuehoFSy+uM8NBuVaY7avp5qrYjQBDzagx2Z5A77FZqPT8/gNLF3HopWkIzaTFaC4JysWXLqg== + dependencies: + lodash "^4.17.21" + validator "^13.9.0" + +express@^4.14.0, express@^4.17.1: version "4.18.2" resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#3fabe08296e930c796c19e3c516979386ba9fd59" integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== @@ -6430,7 +6459,7 @@ lodash.sortby@^4.7.0: resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA== -lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19: +lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -6879,6 +6908,13 @@ node-addon-api@^2.0.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-2.0.2.tgz#432cfa82962ce494b132e9d72a15b29f71ff5d32" integrity sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA== +node-cron@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/node-cron/-/node-cron-3.0.2.tgz#bb0681342bd2dfb568f28e464031280e7f06bd01" + integrity sha512-iP8l0yGlNpE0e6q1o185yOApANRe47UPbLf4YxfbiNHt/RU5eBcGB/e0oudruheSf+LQeDMezqC5BVAb5wwRcQ== + dependencies: + uuid "8.3.2" + node-fetch@^2.6.12: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" @@ -9021,6 +9057,11 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" +unfetch@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unfetch/-/unfetch-5.0.0.tgz#8a5b6e5779ebe4dde0049f7d7a81d4a1af99d142" + integrity sha512-3xM2c89siXg0nHvlmYsQ2zkLASvVMBisZm5lF3gFDqfF2xonNStDJyMpvaOBe0a1Edxmqrf2E0HBdmy9QyZaeg== + unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc" @@ -9154,6 +9195,11 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== +uuid@8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + uuid@^3.3.2: version "3.4.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" @@ -9186,6 +9232,11 @@ validate-npm-package-license@^3.0.1: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" +validator@^13.9.0: + version "13.11.0" + resolved "https://registry.yarnpkg.com/validator/-/validator-13.11.0.tgz#23ab3fd59290c61248364eabf4067f04955fbb1b" + integrity sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ== + varint@^5.0.0: version "5.0.2" resolved "https://registry.yarnpkg.com/varint/-/varint-5.0.2.tgz#5b47f8a947eb668b848e034dcfa87d0ff8a7f7a4"