commit 246ed986288c8d2fc957c9f0ea967ba215cd630f Author: Derrick Hammer Date: Mon Mar 27 08:02:02 2023 -0400 *Initial version diff --git a/build.js b/build.js new file mode 100644 index 0000000..6979e66 --- /dev/null +++ b/build.js @@ -0,0 +1,48 @@ +import esbuild from "esbuild"; +import { readFile } from "fs/promises"; +import path from "path"; + +await esbuild.build({ + entryPoints: ["src/index.ts"], + outfile: "dist/index.js", + format: "esm", + bundle: true, + legalComments: "external", + define: { + global: "self", + "import.meta": "true", + }, + plugins: [ + { + name: "base64", + setup(build) { + build.onResolve({ filter: /\?base64$/ }, (args) => { + return { + path: args.path, + pluginData: { + isAbsolute: path.isAbsolute(args.path), + resolveDir: args.resolveDir, + }, + namespace: "base64-loader", + }; + }); + build.onLoad( + { filter: /\?base64$/, namespace: "base64-loader" }, + async (args) => { + const fullPath = args.pluginData.isAbsolute + ? args.path + : path.join(args.pluginData.resolveDir, args.path); + return { + contents: Buffer.from( + await readFile(fullPath.replace(/\?base64$/, "")) + ).toString("base64"), + loader: "text", + }; + } + ); + }, + }, + ], +}); + +export {}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..a68a999 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "@lumeweb/kernel-eth", + "version": "0.1.0", + "license": "MIT", + "type": "module", + "scripts": { + "build-script": "tsc --project tsconfig.build.json && mv dist-build/build.js dist-build/build.mjs", + "compile": "npm run build-script && rimraf node_modules/@lumeweb/dht-rpc-client/node_modules node_modules/@lumeweb/kernel-dht-client/node_modules/libkmodule && node build.js", + "build": "npm run compile && node ./dist-build/build.mjs dev" + }, + "dependencies": { + "@lumeweb/kernel-rpc-client": "git+https://git.lumeweb.com/LumeWeb/kernel-rpc-client.git", + "libkmodule": "^0.2.53", + "yaml": "^2.2.1" + }, + "devDependencies": { + "@lumeweb/interface-relay": "git+https://git.lumeweb.com/LumeWeb/interface-relay.git", + "@scure/bip39": "^1.2.0", + "@skynetlabs/skynet-nodejs": "^2.9.0", + "@types/node": "^18.15.9", + "@types/read": "^0.0.29", + "buffer": "^6.0.3", + "cli-progress": "^3.12.0", + "esbuild": "^0.17.13", + "esbuild-plugin-wasm": "^1.0.0", + "prettier": "^2.8.7", + "read": "^2.0.0", + "typescript": "^5.0.2" + } +} diff --git a/src-build/build.ts b/src-build/build.ts new file mode 100644 index 0000000..c0833e3 --- /dev/null +++ b/src-build/build.ts @@ -0,0 +1,218 @@ +// This is the standard build script for a kernel module. + +import * as fs from "fs"; +import read from "read"; +import * as bip39 from "@scure/bip39"; +import { wordlist } from "@scure/bip39/wordlists/english.js"; +//@ts-ignore +import { SkynetClient } from "@skynetlabs/skynet-nodejs"; + +// Helper variables to make it easier to return empty values alongside errors. +const nu8 = new Uint8Array(0); +const nkp = { + publicKey: nu8, + secretKey: nu8, +}; + +// readFile is a wrapper for fs.readFileSync that handles the try-catch for the +// caller. +function readFile(fileName: string): [string, string | null] { + try { + let data = fs.readFileSync(fileName, "utf8"); + return [data, null]; + } catch (err) { + return ["", "unable to read file: " + JSON.stringify(err)]; + } +} + +// readFileBinary is a wrapper for fs.readFileSync that handles the try-catch +// for the caller. +function readFileBinary(fileName: string): [Uint8Array, string | null] { + try { + let data = fs.readFileSync(fileName, null); + return [data, null]; + } catch (err) { + return [nu8, "unable to read file: " + JSON.stringify(err)]; + } +} + +// writeFile is a wrapper for fs.writeFileSync which handles the try-catch in a +// non-exception way. +function writeFile(fileName: string, fileData: string): string | null { + try { + fs.writeFileSync(fileName, fileData); + return null; + } catch (err) { + return "unable to write file: " + JSON.stringify(err); + } +} + +// handlePass handles all portions of the script that occur after the password +// has been requested. If no password needs to be requested, handlePass will be +// called with a null input. We need to structure the code this way because the +// password reader is async and we can only access the password when using a +// callback. +function handlePass(password: string) { + try { + // If we are running prod and the seed file does not exist, we + // need to confirm the password and also warn the user to use a + // secure password. + if (!fs.existsSync(seedFile) && process.argv[2] === "prod") { + // The file does not exist, we need to confirm the + // password. + console.log(); + console.log( + "No production entry found for module. Creating new production module..." + ); + console.log( + "If someone can guess the password, they can push arbitrary changes to your module." + ); + console.log("Please use a secure password."); + console.log(); + read( + { prompt: "Confirm Password: ", silent: true }, + function (err: any, confirmPassword: string) { + if (err) { + console.error("unable to fetch password:", err); + process.exit(1); + } + if (password !== confirmPassword) { + console.error("passwords do not match"); + process.exit(1); + } + handlePassConfirm(moduleSalt, password); + } + ); + } else { + // If the seed file does exist, or if we are using dev, + // there's no need to confirm the password but we do + // need to pass the logic off to the handlePassConfirm + // callback. + handlePassConfirm(moduleSalt, password); + } + } catch (err) { + console.error("Unable to read seedFile:", err); + process.exit(1); + } +} + +// handlePassConfirm handles the full script after the confirmation password +// has been provided. If not confirmation password is needed, this function +// will be called anyway using the unconfirmed password as input. +function handlePassConfirm(seed: string, password: string) { + // Create the seedFile if it does not exist. For dev we just save the + // seed to disk outright, because this is a dev build and therefore not + // security sensitive. Also the dev seed does not get pushed to the + // github repo. + // + // For prod, we use the seed to create a new seed (called the shield) + // which allows us to verify that the developer has provided the right + // password when deploying the module. The shield does get pushed to + // the github repo so that the production module is the same on all + // devices. + if (!fs.existsSync(seedFile) && process.argv[2] !== "prod") { + // Generate the seed phrase and write it to the file. + let seedPhrase = bip39.generateMnemonic(wordlist); + let errWF = writeFile(seedFile, seedPhrase); + if (errWF !== null) { + console.error("unable to write file:", errWF); + process.exit(1); + } + } else if (!fs.existsSync(seedFile) && process.argv[2] === "prod") { + // Generate the seed phrase. + let seedPhrase = bip39.generateMnemonic(wordlist); + // Write the registry link to the file. + } + + // Load or verify the seed. If this is prod, the password is used to + // create and verify the seed. If this is dev, we just load the seed + // with no password. + let seedPhrase: string; + let registryLink: string; + if (process.argv[2] === "prod") { + // Generate the seed phrase from the password. + seedPhrase = bip39.generateMnemonic(wordlist); + } else { + let [sp, errRF] = readFile(seedFile); + if (errRF !== null) { + console.error("unable to read seed phrase for dev command from disk"); + process.exit(1); + } + seedPhrase = sp; + } + + let metadata = { + Filename: "index.js", + }; + const client = new SkynetClient("https://web3portal.com"); + client + .uploadFile("dist/index.js") + .then((result: any) => { + console.log("Immutable Link for module:", result); + }) + .catch((err: any) => { + console.error("unable to upload file", err); + process.exit(1); + }); +} + +// Add a newline for readability. +console.log(); + +// Check for a 'dev' or 'prod' input to the script. +if (process.argv.length !== 3) { + console.error("need to provide either 'dev' or 'prod' as an input"); + process.exit(1); +} + +// Create the build folder if it does not exist. +if (!fs.existsSync("build")) { + fs.mkdirSync("build"); +} + +// Determine the seed file. +let seedFile: string; +if (process.argv[2] === "prod") { + seedFile = "module-skylink"; +} else if (process.argv[2] === "dev") { + seedFile = "build/dev-seed"; +} else { + console.error("need to provide either 'dev' or 'prod' as an input"); + process.exit(1); +} + +// If doing a prod deployment, check whether the salt file exists. If it does +// not, create it. +let moduleSalt: string; +if (!fs.existsSync(".module-salt")) { + moduleSalt = bip39.generateMnemonic(wordlist); + let errWF = writeFile(".module-salt", moduleSalt); + if (errWF !== null) { + console.error("unable to write module salt file:", errWF); + process.exit(1); + } +} else { + let [ms, errRF] = readFile(".module-salt"); + if (errRF !== null) { + console.error("unable to read moduleSalt"); + process.exit(1); + } + ms = ms.replace(/\n$/, ""); + moduleSalt = ms; +} + +// Need to get a password if this is a prod build. +if (process.argv[2] === "prod") { + read( + { prompt: "Password: ", silent: true }, + function (err: any, password: string) { + if (err) { + console.error("unable to fetch password:", err); + process.exit(1); + } + handlePass(password); + } + ); +} else { + handlePass(""); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..fdcd32b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,215 @@ +import { ActiveQuery, addHandler, handleMessage } from "libkmodule"; +import { createClient, RpcNetwork } from "@lumeweb/kernel-rpc-client"; +import init, { Client } from "../wasm/helios_ts.js"; +// @ts-ignore +import wasm from "../wasm/helios_ts_bg.wasm?base64"; +import { Buffer } from "buffer"; +import { RPCResponse } from "@lumeweb/interface-relay"; +import { ConsensusRequest, ExecutionRequest } from "./types.js"; + +const CHECKPOINT = + "0x694433ba78dd08280df68d3713c0f79d668dbee9e0922ec2346fcceb1dc3daa9"; + +onmessage = handleMessage; + +let moduleReadyResolve: Function; +let moduleReady: Promise = new Promise((resolve) => { + moduleReadyResolve = resolve; +}); + +let client: Client; +let rpc: RpcNetwork; + +addHandler("presentSeed", handlePresentSeed); + +[ + "eth_getBalance", + "eth_chainId", + "eth_blockNumber", + "eth_getTransactionByHash", + "eth_getTransactionCount", + "eth_getBlockTransactionCountByHash", + "eth_getBlockTransactionCountByNumber", + "eth_getCode", + "eth_call", + "eth_estimateGas", + "eth_gasPrice", + "eth_maxPriorityFeePerGas", + "eth_sendRawTransaction", + "eth_getTransactionReceipt", + "eth_getLogs", + "net_version", +].forEach((rpcMethod) => { + addHandler(rpcMethod, (aq: ActiveQuery) => { + aq.callerInput = aq.callerInput || {}; + aq.callerInput.method = rpcMethod; + handleRpcMethod(aq); + }); +}); + +async function handlePresentSeed() { + await setup(); + moduleReadyResolve(); +} + +async function handleRpcMethod(aq: ActiveQuery) { + await moduleReady; + switch (aq.callerInput?.method) { + case "eth_getBalance": { + return client.get_balance( + aq.callerInput?.params[0], + aq.callerInput?.params[1] + ); + } + case "eth_chainId": { + return client.chain_id(); + } + case "eth_blockNumber": { + return client.get_block_number(); + } + case "eth_getTransactionByHash": { + let tx = await client.get_transaction_by_hash(aq.callerInput?.params[0]); + return mapToObj(tx); + } + case "eth_getTransactionCount": { + return client.get_transaction_count( + aq.callerInput?.params[0], + aq.callerInput?.params[1] + ); + } + case "eth_getBlockTransactionCountByHash": { + return client.get_block_transaction_count_by_hash( + aq.callerInput?.params[0] + ); + } + case "eth_getBlockTransactionCountByNumber": { + return client.get_block_transaction_count_by_number( + aq.callerInput?.params[0] + ); + } + case "eth_getCode": { + return client.get_code( + aq.callerInput?.params[0], + aq.callerInput?.params[1] + ); + } + case "eth_call": { + return client.call(aq.callerInput?.params[0], aq.callerInput?.params[1]); + } + case "eth_estimateGas": { + return client.estimate_gas(aq.callerInput?.params[0]); + } + case "eth_gasPrice": { + return client.gas_price(); + } + case "eth_maxPriorityFeePerGas": { + return client.max_priority_fee_per_gas(); + } + case "eth_sendRawTransaction": { + return client.send_raw_transaction(aq.callerInput?.params[0]); + } + case "eth_getTransactionReceipt": { + return client.get_transaction_receipt(aq.callerInput?.params[0]); + } + case "eth_getLogs": { + return client.get_logs(aq.callerInput?.params[0]); + } + case "net_version": { + return client.chain_id(); + } + } +} + +async function setup() { + rpc = createClient(); + + // @ts-ignore + await ( + await rpc.ready + )(); + + await init(URL.createObjectURL(new Blob([Buffer.from(wasm, "base64")]))); + + (self as any).consensus_rpc_handler = async ( + data: Map + ) => { + const method = data.get("method"); + const path = data.get("path"); + + let query; + let ret: RPCResponse; + + while (true) { + query = await rpc.simpleQuery({ + query: { + module: "eth", + method: "consensus_request", + data: { + method, + path, + } as ConsensusRequest, + }, + options: { + relayTimeout: 10, + queryTimeout: 10, + }, + }); + + ret = await query.result; + if (ret?.data) { + break; + } + } + + if (path.startsWith("/eth/v1/beacon/light_client/updates")) { + return JSON.stringify(ret.data); + } + + return JSON.stringify({ data: ret.data }); + }; + (self as any).execution_rpc_handler = async ( + data: Map + ) => { + const method = data.get("method"); + let params = data.get("params"); + + if (params === "null") { + params = null; + } + + let query; + let ret: RPCResponse; + + while (true) { + query = await rpc.simpleQuery({ + query: { + module: "eth", + method: "execution_request", + data: { + method, + params, + } as ExecutionRequest, + }, + }); + ret = await query.result; + + if (ret?.data) { + break; + } + } + + return JSON.stringify(ret.data); + }; + + client = new Client(CHECKPOINT); + await client.sync(); +} + +function mapToObj(map: Map | undefined): Object | undefined { + if (!map) return undefined; + + return Array.from(map).reduce((obj: any, [key, value]) => { + obj[key] = value; + return obj; + }, {}); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..9923293 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,8 @@ +export interface ConsensusRequest extends RequestInit { + path: string; +} + +export interface ExecutionRequest { + method: string; + params: string; +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..90439ba --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "declaration": true, + "outDir": "./dist-build", + "strict": true, + "esModuleInterop": true + }, + "include": ["src-build"], + "exclude": ["node_modules", "**/__tests__/*"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c24bef2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "esnext", + "declaration": true, + "moduleResolution": "node", + "outDir": "./build", + "strict": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true + }, + "include": ["src"], + "exclude": ["node_modules", "**/__tests__/*"] +}