UNPKG

@tgrospic/rnode-http-js

Version:
375 lines (327 loc) 11.6 kB
import * as R from 'ramda' import { ec } from 'elliptic' import { encodeBase16, decodeBase16 } from './codecs' import { verifyDeployEth, recoverPublicKeyEth } from './eth/eth-sign' import { ethDetected, ethereumAddress, ethereumSign } from './eth/eth-wrapper' import { signDeploy, verifyDeploy, deployDataProtobufSerialize, DeployData, DeploySignedProto } from './rnode-sign' import { RevAccount } from './rev-address' export type RNodeHttp = (httpUrl: string, apiMethod: string, data?: any) => Promise<any> export type RNodeWebAPI = SendDeployEff & GetDeployDataEff & ProposeEff & RawRNodeHttpEff & GetSignedDeployEff export interface RawRNodeHttpEff { /** * Raw RNode HTTP interface. */ readonly rnodeHttp: RNodeHttp } export interface GetSignedDeployEff { /** * Creates signed deploy. */ readonly getSignedDeploy: (node: { httpUrl: string }, account: RevAccount, code: string, phloLimit?: number) => Promise<Deploy> } export interface SendDeployEff { /** * Send deploy to RNode. */ sendDeploy: (node: { httpUrl: string }, account: RevAccount, code: string, phloLimit?: number) => Promise<Deploy> } export interface GetDeployDataEff { /** * Get data from deploy (`rho:rchain:deployId`). */ getDataForDeploy: (node: RNodeHttpUrl, deployId: string, onProgress: () => boolean) => Promise<{data: any, cost: number}> } export interface ProposeEff { /** * Tell the node to propose a block (admin/internal API only). */ propose: (node: RNodeHttpAdminUrl) => Promise<string> } /** * Deploy object with signature */ export interface Deploy { data: DeployData sigAlgorithm: string deployer: string signature: string } /** * Deploy info from block */ export interface DeployResult { // Deploy ID (signature) sig: string // Cost in REV (base units) cost: number // Flag if deploy has error in execution errored: boolean // Error message if charging for deploy failed systemDeployError: string // Deployer public key deployer: string sigAlgorithm: string term: string timestamp: number phloPrice: number phloLimit: number validAfterBlockNumber: number } /** * DOM effects used by RNode web client * - HTTP fetch for communication * - current time for deploy timestamp */ export interface DOMEffects { fetch: typeof fetch now: typeof Date.now } /** * Create instance of RNode Web client. * * `const rnodeWeb = makeRNodeWeb({window.fetch, now: Date.now})` */ export function makeRNodeWeb (effects: DOMEffects): RNodeWebAPI { // Dependencies on browser DOM const { fetch, now } = effects // Basic wrapper around DOM `fetch` method const rnodeHttp = makeRNodeHttpInternal(fetch) // RNode HTTP API methods return { rnodeHttp, sendDeploy : sendDeploy(rnodeHttp, now), getDataForDeploy: getDataForDeploy(rnodeHttp), propose : propose(rnodeHttp), // WIP - offline deploy getSignedDeploy : getSignedDeploy(rnodeHttp, now), } } type MakeRNode = (f: typeof fetch) => RNodeHttp /** * Helper function to create RNode wrapper to Web API. */ const makeRNodeHttpInternal: MakeRNode = domFetch => async (httpUrl, apiMethod, data) => { // Prepare fetch options const postMethods = ['prepare-deploy', 'deploy', 'data-at-name', 'explore-deploy', 'propose'] const isPost = !!data && R.includes(apiMethod, postMethods) const httpMethod = isPost ? 'POST' : 'GET' const url = (method: string) => `${httpUrl}/api/${method}` const body = typeof data === 'string' ? data : JSON.stringify(data) // Make JSON request const opt = { method: httpMethod, body } const resp = await domFetch(url(apiMethod), opt) const result = await resp.json() // Add status if server error if (!resp.ok) { const ex = Error(result) // @ts-ignore ex.status = resp.status throw ex } return result } type SendDeployMethod = (r: RNodeHttp, n: typeof Date.now) => ( node: {httpUrl: string} , account: RevAccount , code: string , phloLimit?: number ) => Promise<Deploy> /** * Creates signed deploy. */ const getSignedDeploy: SendDeployMethod = (rnodeHttp, now) => async ({httpUrl}, account, code, phloLimit) => { // Check if deploy can be signed if (!account.privKey) { const selEthAddr = account.ethAddr if (ethDetected && !!selEthAddr) { // If Metamask is detected check ETH address const ethAddr = await ethereumAddress() const ethFromMM = ethAddr.replace(/^0x/, '').trim().toLowerCase() const ethSaved = selEthAddr.trim().toLowerCase() console.log({ethFromMM, ethSaved}) if (ethAddr.replace(/^0x/, '').trim().toLowerCase() !== selEthAddr.trim().toLowerCase()) throw Error('Selected account is not the same as Metamask account.') } else { throw Error(`Selected account doesn't have private key and cannot be used for signing.`) } } // Get the latest block number const [{ blockNumber }] = await rnodeHttp(httpUrl, 'blocks/1') // Create a deploy const phloLimitNum = !!phloLimit || phloLimit == 0 ? phloLimit : 250e3 const deployData: DeployData = { term: code, phloLimit: phloLimitNum, phloPrice: 1, validAfterBlockNumber: blockNumber, timestamp: now(), } const deploy = !!account.privKey ? signPrivKey(deployData, account.privKey) : await signMetamask(deployData) return deploy } /** * Creates deploy, signing and sending to RNode. */ const sendDeploy: SendDeployMethod = (rnodeHttp, now) => async ({httpUrl}, account, code, phloLimit) => { // Check if deploy can be signed if (!account.privKey) { const ethAddr = account.ethAddr if (ethDetected && !!ethAddr) { // If Metamask is detected check ETH address const ethAddr = await ethereumAddress() if (ethAddr.replace(/^0x/, '') !== account.ethAddr) throw Error('Selected account is not the same as Metamask account.') } else { throw Error(`Selected account doesn't have private key and cannot be used for signing.`) } } // Get the latest block number const [{ blockNumber }] = await rnodeHttp(httpUrl, 'blocks/1') // Create a deploy const phloLimitNum = !!phloLimit || phloLimit == 0 ? phloLimit : 250e3 const deployData: DeployData = { term: code, phloLimit: phloLimitNum, phloPrice: 1, validAfterBlockNumber: blockNumber, timestamp: now(), } const deploy = !!account.privKey ? signPrivKey(deployData, account.privKey) : await signMetamask(deployData) // Send deploy / result is deploy signature (ID) await rnodeHttp(httpUrl, 'deploy', deploy) return deploy } // Singleton timeout handle to ensure only one execution let GET_DATA_TIMEOUT_HANDLE: ReturnType<typeof setTimeout> type DataForDeployMethod = (r: RNodeHttp) => (node: RNodeHttpUrl, deployId: string, onProgress: () => boolean) => Promise<{data: any, cost: number}> /** * Listen for data on `deploy signature` (`rho:rchain:deployId`). */ const getDataForDeploy: DataForDeployMethod = rnodeHttp => async ({httpUrl}, deployId, onProgress) => { GET_DATA_TIMEOUT_HANDLE && clearTimeout(GET_DATA_TIMEOUT_HANDLE) const getData = (resolve: (d: any) => void, reject: (ex: Error) => void) => async () => { const getDataUnsafe = async () => { // Fetch deploy by signature (deployId) const deploy = await fetchDeploy(rnodeHttp)({httpUrl}, deployId) if (deploy) { // Deploy found (added to a block) const args = { depth: 1, name: { UnforgDeploy: { data: deployId } } } // Request for data at deploy signature (deployId) const { exprs } = await rnodeHttp(httpUrl, 'data-at-name', args) // Extract cost from deploy info const { cost } = deploy // Check deploy errors const {errored, systemDeployError} = deploy if (errored) { throw Error(`Deploy error when executing Rholang code.`) } else if (!!systemDeployError) { throw Error(`${systemDeployError} (system error).`) } // Return data with cost (assumes data in one block) resolve({data: exprs[0], cost}) } else { // Retry const cancel = await onProgress() if (!cancel) { GET_DATA_TIMEOUT_HANDLE && clearTimeout(GET_DATA_TIMEOUT_HANDLE) GET_DATA_TIMEOUT_HANDLE = setTimeout(getData(resolve, reject), 7500) } } } try { await getDataUnsafe() } catch (ex) { reject(ex) } } return await new Promise((resolve, reject) => { getData(resolve, reject)() }) } type RNodeHttpUrl = {httpUrl: string} type FetchDeployMethod = (r: RNodeHttp) => (node: RNodeHttpUrl, deployId: string) => Promise<DeployResult> /** * Get deploy result from the block where is proposed (throws error if not found). */ const fetchDeploy: FetchDeployMethod = rnodeHttp => async ({httpUrl}, deployId) => { // Request a block with the deploy const block = await rnodeHttp(httpUrl, `deploy/${deployId}`) .catch(ex => { // Handle response code 400 / deploy not found if (ex.status !== 400) throw ex }) if (block) { const {deploys} = await rnodeHttp(httpUrl, `block/${block.blockHash}`) const deploy = deploys.find(({sig}: {sig: string}) => sig === deployId) if (!deploy) // This should not be possible if block is returned throw Error(`Deploy is not found in the block (${block.blockHash}).`) // Return deploy return deploy } } type RNodeHttpAdminUrl = {httpAdminUrl: string} type ProposeMethod = (r: RNodeHttp) => (node: RNodeHttpAdminUrl) => Promise<string> /** * Helper function to propose via HTTP. */ const propose: ProposeMethod = (rnodeHttp) => ({httpAdminUrl}) => rnodeHttp(httpAdminUrl, 'propose', {}) /** * Creates deploy signature with Metamask. */ export const signMetamask = async function (deployData: DeployData) { // Serialize and sign with Metamask extension // - this will open a popup for user to confirm/review const data = deployDataProtobufSerialize(deployData) const ethAddr = await ethereumAddress() const sigHex = await ethereumSign(data, ethAddr) // Extract public key from signed message and signature const pubKeyHex = recoverPublicKeyEth(data, sigHex) // Create deploy object for signature verification const deploy = { ...deployData, sig: decodeBase16(sigHex), deployer: decodeBase16(pubKeyHex), sigAlgorithm: 'secp256k1:eth', } // Verify signature signed with Metamask const isValidDeploy = verifyDeployEth(deploy) if (!isValidDeploy) throw Error('Metamask signature verification failed.') return toWebDeploy(deploy) } /** * Creates deploy signature with plain private key. */ export const signPrivKey = function (deployData: DeployData, privateKey: ec.KeyPair | string) { // Create signing key const secp256k1 = new ec('secp256k1') const key = secp256k1.keyFromPrivate(privateKey) const deploy = signDeploy(key, deployData) // Verify deploy signature const isValidDeploy = verifyDeploy(deploy) if (!isValidDeploy) throw Error('Deploy signature verification failed.') return toWebDeploy(deploy) } /** * Converts JS object from protobuf spec. to Web API spec. */ export const toWebDeploy = function (deployData: DeploySignedProto): Deploy { const { term, timestamp, phloPrice, phloLimit, validAfterBlockNumber, deployer, sig, sigAlgorithm, } = deployData return { data: { term, timestamp, phloPrice, phloLimit, validAfterBlockNumber }, sigAlgorithm, signature: encodeBase16(sig), deployer: encodeBase16(deployer), } }