/**
* Wallet functions and state
*
* @memberof module:wallet
*/
const assert = require('chai').assert
const {
getNetwork, getConfig, getEndpointURL, Logger, toJS, fromJS,
awaitInputter, awaitOutputter } = require('demo-utils')
const LOGGER = new Logger('wallet')
const BN = require('bn.js')
const randombytes = require('randombytes')
const SignerProvider = require('ethjs-provider-signer')
const ethsign = require('ethjs-signer').sign
const Eth = require('ethjs')
const keys = require('./keys')
const { toWei, fromWei } = require('ethjs-unit')
const { isValidAddress, toChecksumAddress } = require('ethereumjs-util')
const { createInOut } = require('demo-client')
const wallet = {}
/**
* Convenience method to create a new account if no address/password is given,
* otherwise load and unlock the given address
* from the (remote) store. Finally create a signerEth with it.
* Equivalent to calling
* `wallet.loadEncryptedAccount`, `wallet.unlockEncryptedAccount`, `wallet.createSignerEth`
*
* @method prepareSignerEth
* @memberof module:wallet
* @param address {String} `0x`-prefixed Ethereum address to create a signer around
* @param password {String} password to unlock the given Ethereum account
* @return address {String} a `0x`-prefixed Ethereum address, auto-created if missing `address` param
* @return password {String} a hex password string, auto-created if missing `password` param
* @return signerEth {Eth} an Ethereum network object, tied to above address, signing transactions and spending funds from it
*/
wallet.prepareSignerEth = async ({ address, password }) => {
const autoCreate = !address && !password
const pair = (autoCreate) ? (await wallet.createEncryptedAccount()) :
{ address: address, password: password }
const _address = toChecksumAddress(pair.address)
const _password = pair.password
LOGGER.debug('AUTO', _address, _password)
await wallet.loadEncryptedAccount({ address: _address })
await wallet.unlockEncryptedAccount({ address: _address, password: _password })
wallet.lastSignerEth = wallet.createSignerEth({ url: getEndpointURL(), address: _address })
return {
address : _address,
password : _password,
signerEth : wallet.lastSignerEth,
}
}
wallet.validatePassword = async ({ address, password }) => {
try {
await wallet.loadEncryptedAccount({ address })
await wallet.unlockEncryptedAccount({ address, password })
return true
} catch(e) {
return false
}
}
wallet.createFromPrivateString = async ({ privateString }) => {
const account = keys.createFromPrivateString(privateString)
const address = account.get('addressPrefixed')
const password = randombytes(32).toString('hex')
const encryptedJSON = keys.accountToEncryptedJSON({ account: account, password: password })
const result = await wallet.saveEncryptedAccount({
address: address, encryptedAccount: encryptedJSON })
return {
password : password,
address : address,
result : result,
account : account
}
}
/**
* Create a signer provider given the current URL and account.
* TODO: change democracy API to return the endpoint url from a config name
*
* @method createSignerEth
* @memberof module:wallet
* @param url {String} the URL of an endpoint
* @param address {String} `0x`-prefixed Ethereum address of sender
*/
wallet.createSignerEth = ({url, address}) => {
assert( isValidAddress(address), `Invalid Ethereum address ${address}` )
const checksumAddress = toChecksumAddress(address)
const provider = new SignerProvider(url, {
signTransaction: async (rawTx, cb) => {
let account = wallet.accountsMap[checksumAddress]
if ( keys.isAccount(account) ) {
cb(null, ethsign(rawTx, account.get('privatePrefixed') ) )
} else {
throw new Error(`Account ${address} is locked. Call wallet.unlockEncryptedAccount`)
}
},
accounts: (cb) => cb(null, [checksumAddress]),
})
const newEth = new Eth(provider)
newEth.address = checksumAddress
wallet.signersMap[checksumAddress] = newEth
LOGGER.debug(`Added a signer for ${address}`)
return newEth
}
wallet.initialized = false
wallet.inputter = null
wallet.outputter = null
wallet.accountsMap = {}
wallet.signersMap = {}
wallet.relockMap = {}
wallet.eth = getNetwork()
wallet.UNLOCK_TIMEOUT_SECONDS = 600
// The measured gas costs of transferring 100 ETH
wallet.OVERAGE_100_ETH = toWei('0.0134', 'ether')
wallet.unlock_seconds
/**
* Initialize the wallet.
*
* @method init
* @memberof module:wallet
* @param autoConfig {boolean}
* @param unlockSeconds {Number}
*/
wallet.init = async ({ autoConfig, unlockSeconds }) => {
if (wallet.initialized) { LOGGER.debug('Wallet already initialized.'); return }
const _autoConfig = autoConfig || true // save it remotely by default
if (!require('keythereum')) {
LOGGER.error('Missing keythereum, did you add a script tag?')
}
const inout = await createInOut({autoConfig: _autoConfig})
wallet.inputter = inout.inputter
wallet.outputter = inout.outputter
wallet.unlockSeconds = (unlockSeconds) ? unlockSeconds : wallet.UNLOCK_TIMEOUT_SECONDS
wallet.chainId = await wallet.eth.net_version()
wallet.initialized = true
}
/**
* Create an encrypted account.
*
* @method createEncryptedAccount
* @memberof module:wallet
* @param creatorFunc {Function} a factory function with no parameters that
* returns an account as an Immutable Map, with possible extra leading
* bytes in the public key that can be safely ignored / sliced.
* @return address {String} a `0x`-prefixed Ethereum address
* @return password {String} a random password string
* @return encryptedAccount {JSON} the geth-format enciphered Ethereum private key
* @return result {JSON} the result of saving the encryptedAccount to a (remote) store
*/
wallet.createEncryptedAccount = async () => {
if (!wallet.initialized) { LOGGER.error('Call wallet.init() first.') }
const account = keys.create()
const address = account.get('addressPrefixed')
const password = randombytes(32).toString('hex')
const encryptedAccount = keys.accountToEncryptedJSON({
account: account, password: password })
const result = await wallet.saveEncryptedAccount({
address: address, encryptedAccount: encryptedAccount })
assert( result, `Saving encrypted account for ${address} ${encryptedAccount} failed` )
return {
address : address,
password : password,
result : result,
encryptedAccount : encryptedAccount,
}
}
/**
* Retrieves encrypted account for this address from a (possibly remote) persistent store.
*
* @method loadEncryptedAccount
* @memberof module:wallet
* @param address {String} `0x`-prefixed Ethereum address associated with desired account.
* @return encrypted JSON account for the given address.
*/
wallet.loadEncryptedAccount = async ({ address }) => {
if (!wallet.initialized) { LOGGER.error('Call wallet.init() first.') }
const checksumAddress = toChecksumAddress(address)
return awaitInputter(
wallet.inputter(`keys/${wallet.chainId}/${address}`, null),
(encryptedAccount) => {
if (!encryptedAccount) { throw new Error(`Account not found for ${address}`) }
wallet.accountsMap[checksumAddress] =
toJS( encryptedAccount )
LOGGER.debug(`Loaded encrypted account for ${address}`)
return toJS( encryptedAccount )
}
)
}
/**
* Saves the encrypted account to a (remote) store
*
* @method saveEncryptedAccount
* @memberof module:wallet
* @param address {String} a `0x`-prefixed Ethereum address associated with this account
* @param encryptedAccount {JSON} geth-formatted key to save in a (remote) store
*/
wallet.saveEncryptedAccount = async ({ address, encryptedAccount }) => {
if (!wallet.initialized) { LOGGER.error('Call wallet.init() first.') }
const checksumAddress = toChecksumAddress(address)
if (wallet.accountsMap[checksumAddress]) {
throw new Error(`Attempting to overwrite existing account at ${address}`)
}
wallet.accountsMap[checksumAddress] = encryptedAccount
return awaitOutputter(
wallet.outputter( `keys/${wallet.chainId}/${address}`, fromJS(encryptedAccount) ),
// Delay by one second, so that subsequent calls to inputter will return the newly
// saved key
(output) => { return new Promise((resolve) => {
setTimeout(() => { resolve(output) }, 1000)
}) }
)
}
/**
* Unlock an encrypted account.
*
* @method unlockEncryptedAccount
* @memberof module:wallet
* @param address {String} `0x`-prefixed Ethereum address associated with account to unlock
* @param password {String} to unlock the given account
*/
wallet.unlockEncryptedAccount = async ({ address, password }) => {
const checksumAddress = toChecksumAddress(address)
const encryptedAccount = wallet.accountsMap[checksumAddress]
if ( keys.isAccount(encryptedAccount) ) {
throw new Error(`Account ${address} already unlocked`)
}
if (!address || !password) {
throw new Error(`Empty address ${address} or password`)
}
wallet.accountsMap[checksumAddress] =
keys.encryptedJSONToAccount({ encryptedJSON: encryptedAccount,
password: password })
const relockFunc = () => {
wallet.accountsMap[checksumAddress] = encryptedAccount }
const id = setTimeout(relockFunc, wallet.unlockSeconds * 1000)
wallet.relockMap[checksumAddress] = { id: id, relockFunc: relockFunc }
return relockFunc
}
wallet.lockEncryptedAccountSync = ({ address }) => {
const checksumAddress = toChecksumAddress(address)
const { id, relockFunc } = wallet.relockMap[checksumAddress]
if (id) {
clearTimeout(id)
relockFunc()
delete wallet.relockMap[checksumAddress]
LOGGER.debug(`Relocked ${address}`)
}
}
wallet.shutdownSync = () => {
for (var address in wallet.relockMap) {
wallet.lockEncryptedAccountSync({ address: address })
}
}
/**
* Transfer ETH between the given addresses, whose accounts have already been unlocked.
*
* @method pay
* @memberof module:wallet
* @param payAll {boolean} whether to transfer all spendable value
* @param weiValue {String} the amount in wei to transfer (ignored if `payAll` is true)
* @param fromAddress {String} `0x`-prefixed Ethereum address of sender
* @param toAddress {String} `0x`-prefixed Ethereum address of receiver
* @param overage {String} optional, the amount of fees to withhold if `payAll` is true)
* @param label {String} optional, a debug label to log for this transaction
*/
wallet.pay = async ({payAll, weiValue, fromAddress, toAddress, overage, label}) => {
const checksumAddress = toChecksumAddress(fromAddress)
const signer = wallet.signersMap[checksumAddress]
if (!signer) { throw new Error(`No signer created for address ${fromAddress}`) }
return wallet.payTest({eth: signer, fromAddress: fromAddress, payAll: payAll, toAddress: toAddress, weiValue: weiValue, overage: overage, label: label})
}
/**
* Pay from a test account (that is already unlocked on the Ethereum node)
*
* @method payTest
* @memberof module:wallet
* @param weiValue {String} representing the value in wei
* @param fromAddress {String} Ethereum address of payer/sender
* @param toAddress {String} Ethereum address of recipient
*/
wallet.payTest = async ({eth, weiValue, fromAddress, toAddress, payAll, overage, label}) => {
LOGGER.debug('LABEL', label)
let gasLimit = new BN(getConfig()['GAS_LIMIT'])
const gasPrice = new BN(getConfig()['GAS_PRICE'])
const _overage = (overage) ? overage : wallet.OVERAGE_100_ETH
if (payAll) {
LOGGER.debug('OVERAGE', fromWei(_overage, 'ether'))
LOGGER.debug('fromAddress', fromAddress)
LOGGER.debug('toAddress', toAddress)
const balance = await wallet.eth.getBalance(fromAddress)
LOGGER.debug(`payAll for balance of ${fromWei(balance.toString(), 'ether')}`)
const gasEstimate = await wallet.eth.estimateGas({
from: fromAddress, to: toAddress, value: balance, data: '0x'})
gasLimit = gasEstimate
//gasLimit = new BN(gasEstimate).mul(new BN(gasPrice)).mul(new BN(toWei('10', 'gwei')))
weiValue = new BN(balance).sub(new BN(_overage)).toString(10)
LOGGER.debug(`Sendable wei value is ${fromWei(weiValue, 'ether')} ETH`)
}
const _eth = (eth) ? eth : wallet.eth
LOGGER.debug(`Sending wei value is ${fromWei(weiValue, 'ether')} ETH`)
return _eth.sendTransaction({
value : weiValue,
data : '0x',
from : fromAddress,
to : toAddress,
gas : gasLimit,
gasPrice : gasPrice,
nonce : await wallet.eth.getTransactionCount(fromAddress),
})
}
wallet.fromWei = fromWei
wallet.toWei = toWei
module.exports = wallet