webify/exports/api.js

/**
 * The main Democracy Web API exported as part of demo-webify webpack'd bundle.
 * Include BrowserFS for backing the file-based Democracy immutable keystore,
 * sending file-based HTTP REST requests, and managing the in-browser wallet.
 *
 * Call api.init() in your page code when the page has finished loading.
 * Afterwards, you usually want to call the following (in order)
 * api.prepareCachedWallet() to load cached Ethereum address / password in localStorage
 * api.prepareErasePassword() to set up timeout functions to erase the password
 * api.prepareUpdateWhileCached() to set up interval functions while password is still cached.
 * @module api
 */
'use strict'
require("babel-core/register")
require('@babel/polyfill')

const BrowserFS = require('browserfs')
const api = {}

/**
 * Initialize the BrowserFS filesystem with the given file listing as an Object of
 * path to null. Each of these paths will be pre-pended with '/api' to match the
 * Democracy REST server.
 *
 * Example:
 *   api.initFS({ 'notes/abc': null })
 *
 *   will map writes of the form fs.writeFileSync('notes/abc') to
 *   a POST request to http://{db_host}:{db_port}/api/notes/abc
 * @param listingHTTP {Object} paths to map to REST calls.
 * @method initFS
 * @memberof module:api
 */
api.initFS = (listingHTTP) => {
  console.log(JSON.stringify(listingHTTP))
  BrowserFS.FileSystem.LocalStorage.Create(function(e, lsfs) {
    BrowserFS.FileSystem.InMemory.Create(function(e, inMemory) {
      BrowserFS.FileSystem.XmlHttpRequest.Create({
        index: listingHTTP || {},
        baseUrl: api.config.DB_URL + '/api',
      }, (e, httpFS) => {
        BrowserFS.FileSystem.MountableFileSystem.Create({
          '/tmp': inMemory,
          '/': lsfs,
          '/api': httpFS,
        }, function(e, mfs) {
          BrowserFS.initialize(mfs)
          // BFS is now ready to use!
        })
      })
    })
  })
}

require('dotenv').config()
const { toWei } = require('ethjs-unit')
const { isValidAddress } = require('ethereumjs-util')

/**
 * @member utils
 * @memberof module:api
 */
api.utils         = require('demo-utils')
api.LOGGER        = new api.utils.Logger('demo')
api.get           = api.utils.getImmutableKey
api.get           = api.utils.getImmutableKey
api.set           = api.utils.setImmutableKey
api.config        = api.utils.getConfig()
api.eth           = api.utils.getNetwork()
api.fs            = require('fs')
api.path          = require('path')
api.process       = process
api.processGlobal = require('processGlobal')
api.buffer        = require('buffer')
api.bufferGlobal  = require('bufferGlobal')
api.contract      = require('demo-contract')
api.keys          = require('demo-keys')
api.tx            = require('demo-tx')
api.immutable     = require('immutable')
api.chai          = require('chai')
api.secp256k1     = require('@aztec/secp256k1')
api.toWei         = toWei
api.assert        = api.chai.assert

const { prepareSignerEth } = api.keys.wallet
const { Map } = api.immutable

/**
 * Initialize and populate one-time members of the API
 * @param unlockSeconds {Number} default number of seconds to leave wallet
 *   unlocked in memory, before the next refresh.
 * @method init
 * @memberof module:api
 */
api.init = async({ unlockSeconds }) => {
  if (api.thisAddress) { throw new Error('Already initialized this wallet') }
  await api.contract.init()
  api.chainId = api.contract.getChainIdSync()
  if (!demo.keys.wallet.initialized) {
    await demo.keys.wallet.init({ autoConfig: true, unlockSeconds })
  }
}

/**
 * Prepare, and if necessary, create and/or load, the Ethereum wallet (address/password)
 * combo for this browser.
 * Call if you need to initially unlock the wallet, or to unlock again
 * after it has been automatically relocked, but you don't need to run api.init().
 * If the current address and password combo are not valid,
 * create a new one and save into localStorage.
 * Warns user to write down the password.
 *
 * @param enteredPassword {String} password to unlock api.thisAddress if desired
 * @method prepareCachedWallet
 * @memberof module:api
 */
api.prepareCachedWallet = async ({
  enteredPassword,
}) => {

  // Read any saved account params from keystore and attempt to unlock account
  try {
    api.thisAddress = localStorage.getItem(`demo/${api.chainId}/thisAddress`)
    // TODO: Use an Oauth-like credential forwarding system
    // Currently, if this password / device is stolen, we can disable
    // access to the encryptedJSON on our servers
    // Save this in localStorage and not keystore so we can delete it later
    storedPassword = localStorage.getItem(`demo/${api.chainId}/thisPassword`)
  } catch(e) {
    // Currently getImmutableKey throws an error if key is not found
    console.log(`No previous keys/${api.chainId}/thisAddress ` +
                `or keys/${api.chainId}/thisPassword`)
  }

  let storedPassword
  let newAddress, newPassword

  // Try the entered password
  const existingPassword = storedPassword || enteredPassword
  const valid = await demo.keys.wallet.validatePassword({
    address: api.thisAddress,
    password: existingPassword,
  })
  if (valid) {
    console.log(`Valid password found for ${api.thisAddress}`)
  } else {
    console.log(`No valid password found for ${api.thisAddress}`)
  }

  const { signerEth, address, password } = await prepareSignerEth({
    address   : valid ? api.thisAddress : null,
    password  : valid ? existingPassword : null,
  })

  newAddress  = address
  newPassword = password

  if (['test', 'dev'].indexOf(demo.config.DB_NAMESPACE) !== -1 ) {
    localStorage.setItem(`demo/${api.chainId}/thisPassword`, newPassword)
  } else {
    console.warn("Passwords are not cached locally for production environments.")
    console.warn("Consider using Oauth credentials.")
  } 

  // Update thisAddress if we just created a new one
  api.assert( isValidAddress(newAddress) )

  if ( api.thisAddress != newAddress || existingPassword !== newPassword ) {
    console.warn(`Address ${newAddress}`)
    console.warn(`WRITE THIS DOWN: Password ${newPassword}`)
    console.warn(`We cache your password locally for testing / convenience only.`)
    api.thisAddress = newAddress
    localStorage.setItem(`demo/${api.chainId}/thisAddress`, newAddress )
  }

  return newAddress
}

/**
 * Prepare timeout function to erase the password for this wallet
 * in the given number of seconds.
 * The erasePasswordTime is cached, but the callback function must be manually
 * registered with this function after every refresh.
 *
 * @param erasePasswordSeconds {Number} optional
 *   number of seconds to keep password cached, across all page refreshes.
 *   if missing, use the previously cached erasePasswordTime
 * @param erasePasswordCallback {Function} Optional, function to call when removing password.
 * @method prepareErasePassword
 * @memberof module:api
 */
api.prepareErasePassword = async ({
  erasePasswordSeconds,
  erasePasswordCallback,
}) => {
  const now = Date.now()
  const newErasePasswordTime = now + (erasePasswordSeconds * 1000)
  // Read any erase password time and write back a valid one
  try {
    const erasePasswordTime = localStorage.getItem(`demo/${api.chainId}/erasePasswordTime`)
    api.erasePasswordTime =
      erasePasswordSeconds ? newErasePasswordTime : Number(erasePasswordTime)

  } catch(e) {
    // There was no cached erasePasswordTime
    api.erasePasswordTime = newErasePasswordTime
    localStorage.setItem( `demo/${chainId}/erasePasswordTime`, api.erasePasswordTime )
    console.log(`No password erase time found, ` +
                `erasing at ${api.erasePasswordTime}`)
  }

  // Update the relock function, and clear the old timeout function, if necessary
  if (typeof(erasePasswordCallback) === 'function') {
    api.erasePasswordCallback = erasePasswordCallback
    if (api.erasePasswordTimeoutId) {
      clearTimeout(api.erasePasswordTimeoutId)
    }
    api.erasePasswordTimeoutId = setTimeout(() => {
      api.relockErasePassword()
      if (api.updateIntervalId) {
        clearInterval(api.updateIntervalId)
      }
      // Call this last to overwrite any remaining update intervals
      api.erasePasswordCallback()
    }, api.erasePasswordTime - now)
  }
  console.warn(`Erasing password in ${api.erasePasswordTime - now} seconds`)
}

/**
 * Prepare interval function to update some status as long as password has not been erased.
 * The updateWhileCachedCallback must be registered after every refresh,
 * it is not cached.
 *
 * @param updateSeconds {Number} defaults to 1. Number of seconds to wait between updates.
 * @param updateCallback {Function} Optional, callback function which is given the
 *   number of seconds left before password is erased.
 * @method prepareUpdateWhileCached
 * @memberof module:api
 */
api.prepareUpdateWhileCached = async ({
  updateSeconds = 1,
  updateCallback,
}) => {
  // Update the update function, and clear the old interval function, if necessary
  if (typeof(updateCallback) === 'function' && typeof(updateSeconds) === 'number') {
    console.log(`Registered update callback every ${updateSeconds} seconds`)
    api.updateCallback = updateCallback
    if (api.updateIntervalId) {
      clearInterval(api.updateIntervalId)
    }
    api.updateIntervalId = setInterval(async () => {
      await api.updateCallback(api.erasePasswordTime - Date.now())
    }, updateSeconds * 1000)
  }

}

/**
 * Relock all unlocked accounts in the wallet, clear the saved password and
 * associated timeout/interval IDs and callback functions.
 * Called primarily within api.prepareErasePassword.
 * @method relockErasePassword
 * @memberof module:api
 */
api.relockErasePassword = () => {
  demo.keys.wallet.shutdownSync()
  localStorage.setItem(`demo/${api.chainId}/thisPassword`, null)
  localStorage.setItem(`demo/${api.chainId}/relockTime`, null )
  console.log("Erasing password and relocking wallet. Hope you wrote it down.")
}

// So far, we can just prepend 0x04 to the publicKey and have it work with
// aztec.note.create
/*
api.createAztecAccount = () => {
  const {
    privateKey,
    publicKey,
    address,
  } = api.secp256k1.generateAccount()
  api.assert.equal( publicKey.length, 132 )
  const account = api.keys.createFromPrivateString(privateKey.slice(2))
  api.assert.equal( account.get('addressPrefixed'), address )
  return account
    .set('publicPrefixed', publicKey)
    .set('publicString', publicKey.slice(2))
}
*/

/**
 * Fund `api.thisAddress` from the given funderAddress and password.
 * A convenience method primarily for funding test accounts.
 *
 * @param ethValue {String} decimal value in ETH to fund, as a String
 * @param funderAddress {String} `0x`-prefixed Ethereum address of funder.
 * @param funderPassword {String} password for above address.
 */
api.fundWallet = async ({ ethValue, funderAddress, funderPassword }) => {
  if (!api.thisAddress) { throw new Error('Call initWallet() first to unlock account') }
  const { signerEth: funderEth, address, password } = await prepareSignerEth({
    address: funderAddress,
    password: funderPassword, 
  })
  api.assert.equal( funderAddress, address,
               `You cannot create a new address ${address} for funding.`)
  api.assert.equal( funderPassword, password,
               `You cannot create a new password for funding.`)
  api.keys.wallet.pay({
    weiValue    : toWei(ethValue, 'ether'),
    fromAddress : funderAddress,
    toAddress   : api.thisAddress,
  })
  demo.keys.wallet.lastSignerEth = demo.keys.wallet.signersMap[api.thisAddress]
}

module.exports = api