diff --git a/package.json b/package.json index 17dfcdf..64309e6 100644 --- a/package.json +++ b/package.json @@ -11,27 +11,35 @@ "dependencies": { "@hyperswarm/dht": "^6.0.1", "@hyperswarm/dht-relay": "^0.3.0", + "@pokt-network/pocket-js": "^0.8.0-rc", "@root/greenlock": "^4.0.5", + "@solana/web3.js": "^1.47.3", "@types/node": "^18.0.0", "@types/node-cron": "^3.0.2", "@types/ws": "^8.5.3", "@types/xml2js": "^0.4.11", "async-mutex": "^0.3.2", + "dotenv": "^16.0.1", + "ethers": "^5.6.9", "express": "^4.18.1", "greenlock-express": "^4.0.3", "jayson": "^3.6.6", "json-stable-stringify": "^1.0.1", "libskynet": "^0.0.48", "libskynetnode": "^0.1.3", + "minimatch": "^5.1.0", "msgpackr": "^1.6.1", "node-cache": "^5.1.2", "node-cron": "^3.0.1", "node-fetch": "^3.2.6", "random-access-memory": "^4.1.0", + "sprintf-js": "^1.1.2", "xml2js": "^0.4.23" }, "devDependencies": { "@types/express": "^4.17.13", + "@types/minimatch": "^3.0.5", + "@types/sprintf-js": "^1.1.2", "hyper-typings": "^1.0.0", "prettier": "^2.7.1" } diff --git a/src/constant_vars.ts b/src/constant_vars.ts index 31dc777..2fc372a 100644 --- a/src/constant_vars.ts +++ b/src/constant_vars.ts @@ -1,5 +1,33 @@ +import fs from "fs"; +import dotenv from "dotenv"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const envPath = path.resolve(__dirname, "..", "lumerelay.env"); + +if (fs.existsSync(envPath)) { + dotenv.config({ path: envPath }); +} + export const RELAY_PORT = process.env.RELAY_PORT ?? 8080; export const RELAY_DOMAIN = process.env.RELAY_DOMAIN; export const AFRAID_USERNAME = process.env.AFRAID_USERNAME; export const AFRAID_PASSWORD = process.env.AFRAID_PASSWORD; export const RELAY_SEED = process.env.RELAY_SEED; +export const POCKET_APP_ID = process.env.POCKET_APP_ID || false; +export const POCKET_APP_KEY = process.env.POCKET_APP_KEY || false; +export const POCKET_ACCOUNT_PUBLIC_KEY = + process.env.POCKET_ACCOUNT_PUBLIC_KEY || false; +export const POCKET_ACCOUNT_PRIVATE_KEY = + process.env.POCKET_ACCOUNT_PRIVATE_KEY || false; + +export const HSD_NETWORK_TYPE = process.env.HSD_NETWORK || "main"; +export const HSD_HOST = process.env.HSD_HOST || "localhost"; +export const HSD_PORT = Number(process.env.HSD_PORT) || 12037; +export const HSD_API_KEY = process.env.HSD_API_KEY || "foo"; + +export const POCKET_HOST = process.env.POCKET_HOST || "localhost"; +export const POCKET_PORT = process.env.POCKET_PORT || 8081; diff --git a/src/constants.ts b/src/constants.ts index 6b197be..aef5747 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,7 +4,7 @@ let error = false; for (const constant in CONFIG) { // @ts-ignore - if (!CONFIG[constant]) { + if (CONFIG[constant] === null || CONFIG[constant] === undefined) { console.error(`Missing constant ${constant}`); error = true; } @@ -14,4 +14,14 @@ if (error) { process.exit(1); } +let usingPocketGateway = true; + +export function usePocketGateway() { + return usingPocketGateway; +} + +export function updateUsePocketGateway(state: boolean): void { + usingPocketGateway = state; +} + export * from "./constant_vars.js"; diff --git a/src/rpc.ts b/src/rpc.ts index f1a8944..55f3a6b 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -5,16 +5,41 @@ import { Mutex } from "async-mutex"; import { createRequire } from "module"; import NodeCache from "node-cache"; import { get as getDHT } from "./dht.js"; +import { + POCKET_ACCOUNT_PRIVATE_KEY, + POCKET_ACCOUNT_PUBLIC_KEY, + POCKET_APP_ID, + POCKET_APP_KEY, + POCKET_HOST, + POCKET_PORT, +} from "./constant_vars"; +import { updateUsePocketGateway, usePocketGateway } from "./constants"; +import { Server as JSONServer } from "jayson/promise/index.js"; +import { rpcMethods } from "./rpc/index.js"; +import { + Configuration, + HttpRpcProvider, + Pocket, + PocketAAT, +} from "@pokt-network/pocket-js"; +import { + JSONRPCRequest, + JSONRPCResponseWithError, + JSONRPCResponseWithResult, +} from "jayson"; + const require = createRequire(import.meta.url); const stringify = require("json-stable-stringify"); - -const clients: { [chain: string]: any } = {}; const pendingRequests = new NodeCache(); const processedRequests = new NodeCache({ stdTTL: 60 * 60 * 12, }); +let pocketServer: Pocket; +let _aat: PocketAAT; +let jsonServer: jayson.Server; + interface RPCRequest { force: boolean; chain: string; @@ -35,23 +60,6 @@ function hash(data: string): string { return crypto.createHash("sha256").update(data).digest("hex"); } -function getClient(chain: string): Function { - chain = chain.replace(/[^a-z0-9\-]/g, ""); - - if (!(chain in clients)) { - clients[chain] = jayson.Client.http({ - host: process.env.RPC_PROXY_HOST, - port: parseInt(process.env.RPC_PROXY_PORT as string), - path: "/", - headers: { - "X-Chain": chain, - }, - }); - } - - return clients[chain]; -} - function getRequestId(request: RPCRequest) { const clonedRequest = Object.assign({}, request); @@ -98,10 +106,13 @@ async function processRequest(request: RPCRequest): Promise { let error; try { - // @ts-ignore - rpcResp = await getClient(request.chain).request( - request.query, - request.data + rpcResp = await processRpcRequest( + { + method: request.query, + jsonrpc: "2.0", + params: request.data, + } as unknown as JSONRPCRequest, + request.chain ); } catch (e) { error = (e as Error).message; @@ -113,15 +124,22 @@ async function processRequest(request: RPCRequest): Promise { }; if (rpcResp) { + rpcResp = rpcResp as JSONRPCResponseWithResult; if (false === rpcResp.result) { error = true; } + + rpcResp = rpcResp as unknown as JSONRPCResponseWithError; + if (rpcResp.error) { + // @ts-ignore error = rpcResp.error.message; } } - dbData.data = error ? { error } : rpcResp.result; + dbData.data = error + ? { error } + : (rpcResp as unknown as JSONRPCResponseWithResult).result; if (!processedRequests.get(reqId) || request.force) { processedRequests.set(reqId, dbData); @@ -132,7 +150,93 @@ async function processRequest(request: RPCRequest): Promise { return dbData; } +export function updateAat(aat: PocketAAT): void { + _aat = aat; +} + +export function getAat(): PocketAAT { + return _aat; +} +export function getPocketServer(): Pocket { + return pocketServer; +} + +export async function unlockAccount( + accountPrivateKey: string, + accountPublicKey: string, + accountPassphrase: string +): Promise { + try { + const account = await pocketServer.keybase.importAccount( + Buffer.from(accountPrivateKey, "hex"), + accountPassphrase + ); + + if (account instanceof Error) { + // noinspection ExceptionCaughtLocallyJS + throw account; + } + + await pocketServer.keybase.unlockAccount( + account.addressHex, + accountPassphrase, + 0 + ); + + return await PocketAAT.from( + "0.0.1", + accountPublicKey, + accountPublicKey, + accountPrivateKey + ); + } catch (e) { + console.error(e); + process.exit(1); + } +} + +export async function processRpcRequest( + request: JSONRPCRequest, + chain: string +): Promise { + return new Promise((resolve) => { + jsonServer.call( + request, + { chain }, + ( + err?: JSONRPCResponseWithError | null, + result?: JSONRPCResponseWithResult + ): void => { + if (err) { + return resolve(err); + } + resolve(result); + } + ); + }); +} + export async function start() { + if (!POCKET_APP_ID || !POCKET_APP_KEY) { + const dispatchURL = new URL(`http://${POCKET_HOST}:${POCKET_PORT}`); + const rpcProvider = new HttpRpcProvider(dispatchURL); + const configuration = new Configuration(); + pocketServer = new Pocket([dispatchURL], rpcProvider, configuration); + updateUsePocketGateway(false); + } + + if (!usePocketGateway()) { + updateAat( + await unlockAccount( + POCKET_ACCOUNT_PRIVATE_KEY, + POCKET_ACCOUNT_PUBLIC_KEY, + "0" + ) + ); + } + + jsonServer = new JSONServer(rpcMethods, { useContext: true }); + (await getDHT()).on("connection", (socket: any) => { socket.on("data", async (data: any) => { let request: RPCRequest; diff --git a/src/rpc/algorand.ts b/src/rpc/algorand.ts new file mode 100644 index 0000000..41276a8 --- /dev/null +++ b/src/rpc/algorand.ts @@ -0,0 +1,112 @@ +import { maybeMapChainId, reverseMapChainId } from "../util.js"; +import minimatch from "minimatch"; +// @ts-ignore +import HTTPClient from "algosdk/dist/cjs/src/client/client.js"; +import { sprintf } from "sprintf-js"; +import { RpcMethodList } from "./index.js"; +import { POCKET_APP_ID } from "../constants.js"; + +const allowedEndpoints: { [endpoint: string]: ("GET" | "POST")[] } = { + "/v2/teal/compile": ["POST"], + "/v2/accounts/*": ["GET"], +}; + +export function proxyRestMethod( + apiServer: string, + matchChainId: string +): Function { + return async function (args: any, context: object) { + // @ts-ignore + let chain = context.chain; + let chainId = maybeMapChainId(chain); + + if (!chainId) { + throw new Error("Invalid Chain"); + } + + chainId = reverseMapChainId(chainId as string); + if (!chainId || chainId !== matchChainId) { + throw new Error("Invalid Chain"); + } + + let method = args.method ?? false; + let endpoint = args.endpoint ?? false; + let data = args.data ?? false; + let query = args.query ?? false; + let fullHeaders = args.fullHeaders ?? {}; + + fullHeaders = { ...fullHeaders, Referer: "lumeweb_dns_relay" }; + + if (method) { + method = method.toUpperCase(); + } + + if (!endpoint) { + throw new Error("Endpoint Missing"); + } + + let found = false; + + for (const theEndpoint in allowedEndpoints) { + if (minimatch(endpoint, theEndpoint)) { + found = true; + break; + } + } + + if (!found) { + throw new Error("Endpoint Invalid"); + } + + let apiUrl; + try { + apiUrl = sprintf(apiServer, chainId, POCKET_APP_ID); + } catch (e) { + apiUrl = apiServer; + } + + const client = new HTTPClient({}, apiUrl); + let resp; + switch (method) { + case "GET": + resp = await client.get(endpoint, query, fullHeaders); + break; + case "POST": + if (Array.isArray(data?.data)) { + data = new Uint8Array(Buffer.from(data.data)); + } + + resp = await client.post(endpoint, data, { ...fullHeaders }); + break; + default: + throw new Error("Method Invalid"); + } + + const getCircularReplacer = () => { + const seen = new WeakSet(); + return (key: string, value: any): any => { + if (typeof value === "object" && value !== null) { + if (seen.has(value)) { + return; + } + seen.add(value); + } + return value; + }; + }; + + return JSON.parse(JSON.stringify(resp, getCircularReplacer())); + }; +} + +export default { + algorand_rest_request: proxyRestMethod( + "http://mainnet-api.algonode.network", + "algorand-mainnet" + ), + //'algorand_rest_request': proxyRestMethod("https://%s.gateway.pokt.network/v1/lb/%s", "algorand-mainnet"), + algorand_rest_indexer_request: proxyRestMethod( + "http://mainnet-idx.algonode.network", + "algorand-mainnet-indexer" + ), +} as RpcMethodList; diff --git a/src/rpc/common.ts b/src/rpc/common.ts new file mode 100644 index 0000000..43fd411 --- /dev/null +++ b/src/rpc/common.ts @@ -0,0 +1,100 @@ +import { ethers } from "ethers"; +import { PocketAAT } from "@pokt-network/pocket-js"; +import { maybeMapChainId, reverseMapChainId } from "../util.js"; +import { Connection } from "@solana/web3.js"; +import { + POCKET_APP_ID, + POCKET_APP_KEY, + usePocketGateway, +} from "../constants.js"; +import { getAat, getPocketServer } from "../rpc.js"; + +export const chainNetworks = require("../../networks.json"); + +type RpcProviderMethod = (method: string, params: Array) => Promise; + +const gatewayProviders: { [name: string]: RpcProviderMethod } = {}; + +const gatewayMethods: { + [name: string]: (chainId: string) => RpcProviderMethod; +} = { + default: (chainId: string): RpcProviderMethod => { + const provider = new ethers.providers.JsonRpcProvider({ + url: `https://${chainId}.gateway.pokt.network/v1/lb/${POCKET_APP_ID}`, + password: POCKET_APP_KEY, + }); + return provider.send.bind(provider); + }, + "sol-mainnet": (chainId: string): RpcProviderMethod => { + const provider = new Connection( + `https://solana-mainnet.gateway.pokt.network/v1/lb/${POCKET_APP_ID}` + ); + + // @ts-ignore + return provider._rpcRequest.bind(provider); + }, +}; + +export function proxyRpcMethod( + method: string, + chains: string[] = [] +): Function { + return async function (args: any, context: object) { + // @ts-ignore + let chain = context.chain; + let chainId = maybeMapChainId(chain); + + let chainMatch = true; + + if ( + chains.length > 0 && + !chains.includes(chain) && + !chains.includes(chainId.toString()) + ) { + chainMatch = false; + } + + if (!chainId || !chainMatch) { + throw new Error("Invalid Chain"); + } + + if (usePocketGateway()) { + chainId = reverseMapChainId(chainId as string); + if (!chainId) { + throw new Error("Invalid Chain"); + } + + let provider: RpcProviderMethod | boolean = + gatewayProviders[chainId as string] || false; + if (!provider) { + provider = getRpcProvider(chainId as string); + } + gatewayProviders[chainId as string] = provider; + return await provider(method, args); + } + + return await sendRelay(JSON.stringify(args), chainId, getAat()); + }; +} + +// Call this every time you want to fetch RPC data +async function sendRelay( + rpcQuery: string, + blockchain: string, + pocketAAT: PocketAAT +) { + try { + return await getPocketServer().sendRelay(rpcQuery, blockchain, pocketAAT); + } catch (e) { + console.log(e); + throw e; + } +} + +function getRpcProvider(chain: string): RpcProviderMethod { + if (chain in gatewayMethods) { + return gatewayMethods[chain](chain); + } + + return gatewayMethods.default(chain); +} diff --git a/src/rpc/dns.ts b/src/rpc/dns.ts new file mode 100644 index 0000000..f2dc687 --- /dev/null +++ b/src/rpc/dns.ts @@ -0,0 +1,105 @@ +import { isIp } from "../util.js"; +import { RpcMethodList } from "./index.js"; + +const bns = require("bns"); +const { StubResolver, RecursiveResolver } = bns; + +const resolverOpt = { + tcp: true, + inet6: false, + edns: true, + dnssec: true, +}; + +const globalResolver = new RecursiveResolver(resolverOpt); +globalResolver.hints.setDefault(); +globalResolver.open(); + +async function resolveNameServer(ns: string): Promise { + if (isIp(ns)) { + return ns; + } + let result = await getDnsRecords(ns, "A"); + + if (result.length) { + return result[0]; + } + + return false; +} + +async function getDnsRecords( + domain: string, + type: string, + authority: boolean = false, + resolver = globalResolver +): Promise { + let result; + + try { + result = await resolver.lookup(domain, type); + } catch (e) { + return []; + } + + let prop = authority ? "authority" : "answer"; + + if (!result || !result[prop].length) { + return []; + } + + return result[prop].map( + (item: object) => + // @ts-ignore + item.data.address ?? item.data.target ?? item.data.ns ?? null + ); +} + +export default { + dnslookup: async function (args: any) { + let dnsResults: string[] = []; + let domain = args.domain; + let ns = args.nameserver; + let dnsResolver = ns ? new StubResolver(resolverOpt) : globalResolver; + await dnsResolver.open(); + + if (ns) { + let nextNs = ns; + let prevNs = null; + + while (nextNs) { + nextNs = await resolveNameServer(nextNs); + if (!nextNs) { + nextNs = prevNs; + } + + dnsResolver.setServers([nextNs]); + + if (nextNs === prevNs) { + break; + } + let result = await getDnsRecords(domain, "NS", true, dnsResolver); + prevNs = nextNs; + nextNs = result.length ? result[0] : false; + } + } + + for (const queryType of ["CNAME", "A"]) { + let result = await getDnsRecords(domain, queryType, false, dnsResolver); + + if (result) { + dnsResults = dnsResults.concat(result); + } + } + + await dnsResolver.close(); + + dnsResults = dnsResults.filter(Boolean); + + if (dnsResults.length) { + return dnsResults[0]; + } + + return false; + }, +} as RpcMethodList; diff --git a/src/rpc/evm.ts b/src/rpc/evm.ts new file mode 100644 index 0000000..db2203a --- /dev/null +++ b/src/rpc/evm.ts @@ -0,0 +1,14 @@ +import { proxyRpcMethod } from "./common.js"; +import { RpcMethodList } from "./index.js"; + +const rpcMethods: RpcMethodList = {}; + +function proxyEvmRpcMethod(method: string): Function { + return proxyRpcMethod(method); +} + +["eth_call", "eth_chainId", "net_version"].forEach((method) => { + rpcMethods[method] = proxyEvmRpcMethod(method); +}); + +export default rpcMethods; diff --git a/src/rpc/handshake.ts b/src/rpc/handshake.ts new file mode 100644 index 0000000..a9ee7a3 --- /dev/null +++ b/src/rpc/handshake.ts @@ -0,0 +1,27 @@ +import { RpcMethodList } from "./index.js"; +import { + HSD_API_KEY, + HSD_HOST, + HSD_NETWORK_TYPE, + HSD_PORT, +} from "../constant_vars.js"; + +const { NodeClient } = require("hs-client"); + +const hnsClient = new NodeClient({ + network: HSD_NETWORK_TYPE, + host: HSD_HOST, + port: HSD_PORT, + apiKey: HSD_API_KEY, +}); + +export default { + getnameresource: async function (args: any, context: object) { + // @ts-ignore + if ("hns" !== context.chain) { + throw new Error("Invalid Chain"); + } + + return await hnsClient.execute("getnameresource", args); + }, +} as RpcMethodList; diff --git a/src/rpc/index.ts b/src/rpc/index.ts new file mode 100644 index 0000000..3aa96ba --- /dev/null +++ b/src/rpc/index.ts @@ -0,0 +1,18 @@ +export type RpcMethodList = { [name: string]: Function }; + +export * from "./common.js"; + +import { default as DnsMethods } from "./dns.js"; +import { default as EvmMethods } from "./evm.js"; +import { default as HnsMethods } from "./handshake.js"; +import { default as SolMethods } from "./solana.js"; +import { default as AlgoMethods } from "./algorand.js"; + +export const rpcMethods: RpcMethodList = Object.assign( + {}, + DnsMethods, + EvmMethods, + HnsMethods, + SolMethods, + AlgoMethods +); diff --git a/src/rpc/solana.ts b/src/rpc/solana.ts new file mode 100644 index 0000000..1581f3d --- /dev/null +++ b/src/rpc/solana.ts @@ -0,0 +1,8 @@ +import { chainNetworks, proxyRpcMethod } from "./common.js"; +import { RpcMethodList } from "./index.js"; + +export default { + getAccountInfo: proxyRpcMethod("getAccountInfo", [ + chainNetworks["sol-mainnet"], + ]), +} as RpcMethodList; diff --git a/src/util.ts b/src/util.ts index 8a993ab..4e64146 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,4 +1,37 @@ +import { chainNetworks } from "./rpc/index.js"; + export function errorExit(msg: string): void { console.error(msg); process.exit(1); } + +export function maybeMapChainId(chain: string): string | boolean { + if (chain in chainNetworks) { + return chainNetworks[chain]; + } + + if ( + [parseInt(chain, 16).toString(), parseInt(chain, 10).toString()].includes( + chain.toLowerCase() + ) + ) { + return chain; + } + + return false; +} + +export function reverseMapChainId(chainId: string): string | boolean { + let vals = Object.values(chainNetworks); + if (!vals.includes(chainId)) { + return false; + } + + return Object.keys(chainNetworks)[vals.indexOf(chainId)]; +} + +export function isIp(ip: string) { + return /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test( + ip + ); +}