Merge pull request #816 from MetaMask/i328-MultiVault
Multi vault to Dev Branchfeature/default_network_editable
commit
6400eb8453
@ -0,0 +1,567 @@ |
|||||||
|
const async = require('async') |
||||||
|
const ethUtil = require('ethereumjs-util') |
||||||
|
const ethBinToOps = require('eth-bin-to-ops') |
||||||
|
const EthQuery = require('eth-query') |
||||||
|
const bip39 = require('bip39') |
||||||
|
const Transaction = require('ethereumjs-tx') |
||||||
|
const EventEmitter = require('events').EventEmitter |
||||||
|
|
||||||
|
const normalize = require('./lib/sig-util').normalize |
||||||
|
const encryptor = require('./lib/encryptor') |
||||||
|
const messageManager = require('./lib/message-manager') |
||||||
|
const autoFaucet = require('./lib/auto-faucet') |
||||||
|
const IdStoreMigrator = require('./lib/idStore-migrator') |
||||||
|
const BN = ethUtil.BN |
||||||
|
|
||||||
|
// Keyrings:
|
||||||
|
const SimpleKeyring = require('./keyrings/simple') |
||||||
|
const HdKeyring = require('./keyrings/hd') |
||||||
|
const keyringTypes = [ |
||||||
|
SimpleKeyring, |
||||||
|
HdKeyring, |
||||||
|
] |
||||||
|
|
||||||
|
const createId = require('./lib/random-id') |
||||||
|
|
||||||
|
module.exports = class KeyringController extends EventEmitter { |
||||||
|
|
||||||
|
constructor (opts) { |
||||||
|
super() |
||||||
|
this.configManager = opts.configManager |
||||||
|
this.ethStore = opts.ethStore |
||||||
|
this.encryptor = encryptor |
||||||
|
this.keyringTypes = keyringTypes |
||||||
|
|
||||||
|
this.keyrings = [] |
||||||
|
this.identities = {} // Essentially a name hash
|
||||||
|
|
||||||
|
this._unconfTxCbs = {} |
||||||
|
this._unconfMsgCbs = {} |
||||||
|
|
||||||
|
this.getNetwork = opts.getNetwork |
||||||
|
|
||||||
|
// TEMPORARY UNTIL FULL DEPRECATION:
|
||||||
|
this.idStoreMigrator = new IdStoreMigrator({ |
||||||
|
configManager: this.configManager, |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
getState () { |
||||||
|
const configManager = this.configManager |
||||||
|
const address = configManager.getSelectedAccount() |
||||||
|
const wallet = configManager.getWallet() // old style vault
|
||||||
|
const vault = configManager.getVault() // new style vault
|
||||||
|
|
||||||
|
return { |
||||||
|
seedWords: this.configManager.getSeedWords(), |
||||||
|
isInitialized: (!!wallet || !!vault), |
||||||
|
isUnlocked: !!this.key, |
||||||
|
isDisclaimerConfirmed: this.configManager.getConfirmedDisclaimer(), // AUDIT this.configManager.getConfirmedDisclaimer(),
|
||||||
|
unconfTxs: this.configManager.unconfirmedTxs(), |
||||||
|
transactions: this.configManager.getTxList(), |
||||||
|
unconfMsgs: messageManager.unconfirmedMsgs(), |
||||||
|
messages: messageManager.getMsgList(), |
||||||
|
selectedAddress: address, |
||||||
|
selectedAccount: address, |
||||||
|
shapeShiftTxList: this.configManager.getShapeShiftTxList(), |
||||||
|
currentFiat: this.configManager.getCurrentFiat(), |
||||||
|
conversionRate: this.configManager.getConversionRate(), |
||||||
|
conversionDate: this.configManager.getConversionDate(), |
||||||
|
keyringTypes: this.keyringTypes.map((krt) => krt.type()), |
||||||
|
identities: this.identities, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
setStore (ethStore) { |
||||||
|
this.ethStore = ethStore |
||||||
|
} |
||||||
|
|
||||||
|
createNewVaultAndKeychain (password, entropy, cb) { |
||||||
|
this.createNewVault(password, entropy, (err) => { |
||||||
|
if (err) return cb(err) |
||||||
|
this.createFirstKeyTree(password, cb) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
createNewVaultAndRestore (password, seed, cb) { |
||||||
|
if (typeof password !== 'string') { |
||||||
|
return cb('Password must be text.') |
||||||
|
} |
||||||
|
|
||||||
|
if (!bip39.validateMnemonic(seed)) { |
||||||
|
return cb('Seed phrase is invalid.') |
||||||
|
} |
||||||
|
|
||||||
|
this.clearKeyrings() |
||||||
|
|
||||||
|
this.createNewVault(password, '', (err) => { |
||||||
|
if (err) return cb(err) |
||||||
|
this.addNewKeyring('HD Key Tree', { |
||||||
|
mnemonic: seed, |
||||||
|
numberOfAccounts: 1, |
||||||
|
}, (err) => { |
||||||
|
if (err) return cb(err) |
||||||
|
const firstKeyring = this.keyrings[0] |
||||||
|
const accounts = firstKeyring.getAccounts() |
||||||
|
const firstAccount = accounts[0] |
||||||
|
const hexAccount = normalize(firstAccount) |
||||||
|
this.configManager.setSelectedAccount(hexAccount) |
||||||
|
this.setupAccounts(accounts) |
||||||
|
|
||||||
|
this.emit('update') |
||||||
|
cb() |
||||||
|
}) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
migrateAndGetKey (password) { |
||||||
|
let key |
||||||
|
const shouldMigrate = !!this.configManager.getWallet() && !this.configManager.getVault() |
||||||
|
|
||||||
|
return this.loadKey(password) |
||||||
|
.then((derivedKey) => { |
||||||
|
key = derivedKey |
||||||
|
this.key = key |
||||||
|
return this.idStoreMigrator.oldSeedForPassword(password) |
||||||
|
}) |
||||||
|
.then((serialized) => { |
||||||
|
if (serialized && shouldMigrate) { |
||||||
|
const keyring = this.restoreKeyring(serialized) |
||||||
|
this.keyrings.push(keyring) |
||||||
|
this.configManager.setSelectedAccount(keyring.getAccounts()[0]) |
||||||
|
} |
||||||
|
return key |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
createNewVault (password, entropy, cb) { |
||||||
|
const configManager = this.configManager |
||||||
|
const salt = this.encryptor.generateSalt() |
||||||
|
configManager.setSalt(salt) |
||||||
|
|
||||||
|
return this.migrateAndGetKey(password) |
||||||
|
.then(() => { |
||||||
|
return this.persistAllKeyrings() |
||||||
|
}) |
||||||
|
.then(() => { |
||||||
|
cb() |
||||||
|
}) |
||||||
|
.catch((err) => { |
||||||
|
cb(err) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
createFirstKeyTree (password, cb) { |
||||||
|
this.clearKeyrings() |
||||||
|
this.addNewKeyring('HD Key Tree', {numberOfAccounts: 1}, (err) => { |
||||||
|
const accounts = this.keyrings[0].getAccounts() |
||||||
|
const firstAccount = accounts[0] |
||||||
|
const hexAccount = normalize(firstAccount) |
||||||
|
this.configManager.setSelectedAccount(firstAccount) |
||||||
|
|
||||||
|
this.placeSeedWords() |
||||||
|
autoFaucet(hexAccount) |
||||||
|
this.setupAccounts(accounts) |
||||||
|
this.persistAllKeyrings() |
||||||
|
.then(() => { |
||||||
|
cb(err) |
||||||
|
}) |
||||||
|
.catch((reason) => { |
||||||
|
cb(reason) |
||||||
|
}) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
placeSeedWords () { |
||||||
|
const firstKeyring = this.keyrings[0] |
||||||
|
const seedWords = firstKeyring.serialize().mnemonic |
||||||
|
this.configManager.setSeedWords(seedWords) |
||||||
|
} |
||||||
|
|
||||||
|
submitPassword (password, cb) { |
||||||
|
this.migrateAndGetKey(password) |
||||||
|
.then((key) => { |
||||||
|
return this.unlockKeyrings(key) |
||||||
|
}) |
||||||
|
.then((keyrings) => { |
||||||
|
this.keyrings = keyrings |
||||||
|
this.setupAccounts() |
||||||
|
this.emit('update') |
||||||
|
cb(null, this.getState()) |
||||||
|
}) |
||||||
|
.catch((err) => { |
||||||
|
console.error(err) |
||||||
|
cb(err) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
loadKey (password) { |
||||||
|
const salt = this.configManager.getSalt() || this.encryptor.generateSalt() |
||||||
|
return this.encryptor.keyFromPassword(password + salt) |
||||||
|
.then((key) => { |
||||||
|
this.key = key |
||||||
|
this.configManager.setSalt(salt) |
||||||
|
return key |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
addNewKeyring (type, opts, cb) { |
||||||
|
const Keyring = this.getKeyringClassForType(type) |
||||||
|
const keyring = new Keyring(opts) |
||||||
|
const accounts = keyring.getAccounts() |
||||||
|
|
||||||
|
this.keyrings.push(keyring) |
||||||
|
this.setupAccounts(accounts) |
||||||
|
this.persistAllKeyrings() |
||||||
|
.then(() => { |
||||||
|
cb() |
||||||
|
}) |
||||||
|
.catch((reason) => { |
||||||
|
cb(reason) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
addNewAccount (keyRingNum = 0, cb) { |
||||||
|
const ring = this.keyrings[keyRingNum] |
||||||
|
const accounts = ring.addAccounts(1) |
||||||
|
this.setupAccounts(accounts) |
||||||
|
this.persistAllKeyrings() |
||||||
|
.then(() => { |
||||||
|
cb() |
||||||
|
}) |
||||||
|
.catch((reason) => { |
||||||
|
cb(reason) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
setupAccounts (accounts) { |
||||||
|
var arr = accounts || this.getAccounts() |
||||||
|
arr.forEach((account) => { |
||||||
|
this.getBalanceAndNickname(account) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// Takes an account address and an iterator representing
|
||||||
|
// the current number of named accounts.
|
||||||
|
getBalanceAndNickname (account) { |
||||||
|
const address = normalize(account) |
||||||
|
this.ethStore.addAccount(address) |
||||||
|
this.createNickname(address) |
||||||
|
} |
||||||
|
|
||||||
|
createNickname (address) { |
||||||
|
const hexAddress = normalize(address) |
||||||
|
var i = Object.keys(this.identities).length |
||||||
|
const oldNickname = this.configManager.nicknameForWallet(address) |
||||||
|
const name = oldNickname || `Account ${++i}` |
||||||
|
this.identities[hexAddress] = { |
||||||
|
address: hexAddress, |
||||||
|
name, |
||||||
|
} |
||||||
|
return this.saveAccountLabel(hexAddress, name) |
||||||
|
} |
||||||
|
|
||||||
|
saveAccountLabel (account, label, cb) { |
||||||
|
const address = normalize(account) |
||||||
|
const configManager = this.configManager |
||||||
|
configManager.setNicknameForWallet(address, label) |
||||||
|
this.identities[address].name = label |
||||||
|
if (cb) { |
||||||
|
cb(null, label) |
||||||
|
} else { |
||||||
|
return label |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
persistAllKeyrings () { |
||||||
|
const serialized = this.keyrings.map((k) => { |
||||||
|
return { |
||||||
|
type: k.type, |
||||||
|
data: k.serialize(), |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
return this.encryptor.encryptWithKey(this.key, serialized) |
||||||
|
.then((encryptedString) => { |
||||||
|
this.configManager.setVault(encryptedString) |
||||||
|
return true |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
unlockKeyrings (key) { |
||||||
|
const encryptedVault = this.configManager.getVault() |
||||||
|
return this.encryptor.decryptWithKey(key, encryptedVault) |
||||||
|
.then((vault) => { |
||||||
|
vault.forEach(this.restoreKeyring.bind(this)) |
||||||
|
return this.keyrings |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
restoreKeyring (serialized) { |
||||||
|
const { type, data } = serialized |
||||||
|
const Keyring = this.getKeyringClassForType(type) |
||||||
|
const keyring = new Keyring() |
||||||
|
keyring.deserialize(data) |
||||||
|
|
||||||
|
const accounts = keyring.getAccounts() |
||||||
|
this.setupAccounts(accounts) |
||||||
|
|
||||||
|
this.keyrings.push(keyring) |
||||||
|
return keyring |
||||||
|
} |
||||||
|
|
||||||
|
getKeyringClassForType (type) { |
||||||
|
const Keyring = this.keyringTypes.reduce((res, kr) => { |
||||||
|
if (kr.type() === type) { |
||||||
|
return kr |
||||||
|
} else { |
||||||
|
return res |
||||||
|
} |
||||||
|
}) |
||||||
|
return Keyring |
||||||
|
} |
||||||
|
|
||||||
|
getAccounts () { |
||||||
|
const keyrings = this.keyrings || [] |
||||||
|
return keyrings.map(kr => kr.getAccounts()) |
||||||
|
.reduce((res, arr) => { |
||||||
|
return res.concat(arr) |
||||||
|
}, []) |
||||||
|
} |
||||||
|
|
||||||
|
setSelectedAddress (address, cb) { |
||||||
|
var addr = normalize(address) |
||||||
|
this.configManager.setSelectedAccount(addr) |
||||||
|
cb(null, addr) |
||||||
|
} |
||||||
|
|
||||||
|
addUnconfirmedTransaction (txParams, onTxDoneCb, cb) { |
||||||
|
var self = this |
||||||
|
const configManager = this.configManager |
||||||
|
|
||||||
|
// create txData obj with parameters and meta data
|
||||||
|
var time = (new Date()).getTime() |
||||||
|
var txId = createId() |
||||||
|
txParams.metamaskId = txId |
||||||
|
txParams.metamaskNetworkId = this.getNetwork() |
||||||
|
var txData = { |
||||||
|
id: txId, |
||||||
|
txParams: txParams, |
||||||
|
time: time, |
||||||
|
status: 'unconfirmed', |
||||||
|
gasMultiplier: configManager.getGasMultiplier() || 1, |
||||||
|
metamaskNetworkId: this.getNetwork(), |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
// keep the onTxDoneCb around for after approval/denial (requires user interaction)
|
||||||
|
// This onTxDoneCb fires completion to the Dapp's write operation.
|
||||||
|
this._unconfTxCbs[txId] = onTxDoneCb |
||||||
|
|
||||||
|
var provider = this.ethStore._query.currentProvider |
||||||
|
var query = new EthQuery(provider) |
||||||
|
|
||||||
|
// calculate metadata for tx
|
||||||
|
async.parallel([ |
||||||
|
analyzeForDelegateCall, |
||||||
|
estimateGas, |
||||||
|
], didComplete) |
||||||
|
|
||||||
|
// perform static analyis on the target contract code
|
||||||
|
function analyzeForDelegateCall (cb) { |
||||||
|
if (txParams.to) { |
||||||
|
query.getCode(txParams.to, function (err, result) { |
||||||
|
if (err) return cb(err) |
||||||
|
var code = ethUtil.toBuffer(result) |
||||||
|
if (code !== '0x') { |
||||||
|
var ops = ethBinToOps(code) |
||||||
|
var containsDelegateCall = ops.some((op) => op.name === 'DELEGATECALL') |
||||||
|
txData.containsDelegateCall = containsDelegateCall |
||||||
|
cb() |
||||||
|
} else { |
||||||
|
cb() |
||||||
|
} |
||||||
|
}) |
||||||
|
} else { |
||||||
|
cb() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function estimateGas (cb) { |
||||||
|
query.estimateGas(txParams, function (err, result) { |
||||||
|
if (err) return cb(err) |
||||||
|
txData.estimatedGas = self.addGasBuffer(result) |
||||||
|
cb() |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function didComplete (err) { |
||||||
|
if (err) return cb(err) |
||||||
|
configManager.addTx(txData) |
||||||
|
// signal update
|
||||||
|
self.emit('update') |
||||||
|
// signal completion of add tx
|
||||||
|
cb(null, txData) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
addUnconfirmedMessage (msgParams, cb) { |
||||||
|
// create txData obj with parameters and meta data
|
||||||
|
var time = (new Date()).getTime() |
||||||
|
var msgId = createId() |
||||||
|
var msgData = { |
||||||
|
id: msgId, |
||||||
|
msgParams: msgParams, |
||||||
|
time: time, |
||||||
|
status: 'unconfirmed', |
||||||
|
} |
||||||
|
messageManager.addMsg(msgData) |
||||||
|
console.log('addUnconfirmedMessage:', msgData) |
||||||
|
|
||||||
|
// keep the cb around for after approval (requires user interaction)
|
||||||
|
// This cb fires completion to the Dapp's write operation.
|
||||||
|
this._unconfMsgCbs[msgId] = cb |
||||||
|
|
||||||
|
// signal update
|
||||||
|
this.emit('update') |
||||||
|
return msgId |
||||||
|
} |
||||||
|
|
||||||
|
approveTransaction (txId, cb) { |
||||||
|
const configManager = this.configManager |
||||||
|
var approvalCb = this._unconfTxCbs[txId] || noop |
||||||
|
|
||||||
|
// accept tx
|
||||||
|
cb() |
||||||
|
approvalCb(null, true) |
||||||
|
// clean up
|
||||||
|
configManager.confirmTx(txId) |
||||||
|
delete this._unconfTxCbs[txId] |
||||||
|
this.emit('update') |
||||||
|
} |
||||||
|
|
||||||
|
cancelTransaction (txId, cb) { |
||||||
|
const configManager = this.configManager |
||||||
|
var approvalCb = this._unconfTxCbs[txId] || noop |
||||||
|
|
||||||
|
// reject tx
|
||||||
|
approvalCb(null, false) |
||||||
|
// clean up
|
||||||
|
configManager.rejectTx(txId) |
||||||
|
delete this._unconfTxCbs[txId] |
||||||
|
|
||||||
|
if (cb && typeof cb === 'function') { |
||||||
|
cb() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
signTransaction (txParams, cb) { |
||||||
|
try { |
||||||
|
const address = normalize(txParams.from) |
||||||
|
const keyring = this.getKeyringForAccount(address) |
||||||
|
|
||||||
|
// Handle gas pricing
|
||||||
|
var gasMultiplier = this.configManager.getGasMultiplier() || 1 |
||||||
|
var gasPrice = new BN(ethUtil.stripHexPrefix(txParams.gasPrice), 16) |
||||||
|
gasPrice = gasPrice.mul(new BN(gasMultiplier * 100, 10)).div(new BN(100, 10)) |
||||||
|
txParams.gasPrice = ethUtil.intToHex(gasPrice.toNumber()) |
||||||
|
|
||||||
|
// normalize values
|
||||||
|
txParams.to = normalize(txParams.to) |
||||||
|
txParams.from = normalize(txParams.from) |
||||||
|
txParams.value = normalize(txParams.value) |
||||||
|
txParams.data = normalize(txParams.data) |
||||||
|
txParams.gasLimit = normalize(txParams.gasLimit || txParams.gas) |
||||||
|
txParams.nonce = normalize(txParams.nonce) |
||||||
|
|
||||||
|
let tx = new Transaction(txParams) |
||||||
|
tx = keyring.signTransaction(address, tx) |
||||||
|
|
||||||
|
// Add the tx hash to the persisted meta-tx object
|
||||||
|
var txHash = ethUtil.bufferToHex(tx.hash()) |
||||||
|
var metaTx = this.configManager.getTx(txParams.metamaskId) |
||||||
|
metaTx.hash = txHash |
||||||
|
this.configManager.updateTx(metaTx) |
||||||
|
|
||||||
|
// return raw serialized tx
|
||||||
|
var rawTx = ethUtil.bufferToHex(tx.serialize()) |
||||||
|
cb(null, rawTx) |
||||||
|
} catch (e) { |
||||||
|
cb(e) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
signMessage (msgParams, cb) { |
||||||
|
try { |
||||||
|
const keyring = this.getKeyringForAccount(msgParams.from) |
||||||
|
const address = normalize(msgParams.from) |
||||||
|
const rawSig = keyring.signMessage(address, msgParams.data) |
||||||
|
cb(null, rawSig) |
||||||
|
} catch (e) { |
||||||
|
cb(e) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
getKeyringForAccount (address) { |
||||||
|
const hexed = normalize(address) |
||||||
|
return this.keyrings.find((ring) => { |
||||||
|
return ring.getAccounts() |
||||||
|
.map(normalize) |
||||||
|
.includes(hexed) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
cancelMessage (msgId, cb) { |
||||||
|
if (cb && typeof cb === 'function') { |
||||||
|
cb() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
setLocked (cb) { |
||||||
|
this.key = null |
||||||
|
this.keyrings = [] |
||||||
|
this.emit('update') |
||||||
|
cb() |
||||||
|
} |
||||||
|
|
||||||
|
exportAccount (address, cb) { |
||||||
|
try { |
||||||
|
const keyring = this.getKeyringForAccount(address) |
||||||
|
const privateKey = keyring.exportAccount(normalize(address)) |
||||||
|
cb(null, privateKey) |
||||||
|
} catch (e) { |
||||||
|
cb(e) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
addGasBuffer (gas) { |
||||||
|
const gasBuffer = new BN('100000', 10) |
||||||
|
const bnGas = new BN(ethUtil.stripHexPrefix(gas), 16) |
||||||
|
const correct = bnGas.add(gasBuffer) |
||||||
|
return ethUtil.addHexPrefix(correct.toString(16)) |
||||||
|
} |
||||||
|
|
||||||
|
clearSeedWordCache (cb) { |
||||||
|
this.configManager.setSeedWords(null) |
||||||
|
cb(null, this.configManager.getSelectedAccount()) |
||||||
|
} |
||||||
|
|
||||||
|
clearKeyrings () { |
||||||
|
let accounts |
||||||
|
try { |
||||||
|
accounts = Object.keys(this.ethStore._currentState.accounts) |
||||||
|
} catch (e) { |
||||||
|
accounts = [] |
||||||
|
} |
||||||
|
accounts.forEach((address) => { |
||||||
|
this.ethStore.removeAccount(address) |
||||||
|
}) |
||||||
|
|
||||||
|
this.keyrings = [] |
||||||
|
this.identities = {} |
||||||
|
this.configManager.setSelectedAccount() |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
function noop () {} |
@ -0,0 +1,101 @@ |
|||||||
|
const EventEmitter = require('events').EventEmitter |
||||||
|
const hdkey = require('ethereumjs-wallet/hdkey') |
||||||
|
const bip39 = require('bip39') |
||||||
|
const ethUtil = require('ethereumjs-util') |
||||||
|
const sigUtil = require('../lib/sig-util') |
||||||
|
|
||||||
|
const type = 'HD Key Tree' |
||||||
|
|
||||||
|
const hdPathString = `m/44'/60'/0'/0` |
||||||
|
|
||||||
|
module.exports = class HdKeyring extends EventEmitter { |
||||||
|
|
||||||
|
static type () { |
||||||
|
return type |
||||||
|
} |
||||||
|
|
||||||
|
constructor (opts = {}) { |
||||||
|
super() |
||||||
|
this.type = type |
||||||
|
this.deserialize(opts) |
||||||
|
} |
||||||
|
|
||||||
|
deserialize (opts = {}) { |
||||||
|
this.opts = opts || {} |
||||||
|
this.wallets = [] |
||||||
|
this.mnemonic = null |
||||||
|
this.root = null |
||||||
|
|
||||||
|
if ('mnemonic' in opts) { |
||||||
|
this.initFromMnemonic(opts.mnemonic) |
||||||
|
} |
||||||
|
|
||||||
|
if ('numberOfAccounts' in opts) { |
||||||
|
this.addAccounts(opts.numberOfAccounts) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
initFromMnemonic (mnemonic) { |
||||||
|
this.mnemonic = mnemonic |
||||||
|
const seed = bip39.mnemonicToSeed(mnemonic) |
||||||
|
this.hdWallet = hdkey.fromMasterSeed(seed) |
||||||
|
this.root = this.hdWallet.derivePath(hdPathString) |
||||||
|
} |
||||||
|
|
||||||
|
serialize () { |
||||||
|
return { |
||||||
|
mnemonic: this.mnemonic, |
||||||
|
numberOfAccounts: this.wallets.length, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
exportAccount (address) { |
||||||
|
const wallet = this.getWalletForAccount(address) |
||||||
|
return wallet.getPrivateKey().toString('hex') |
||||||
|
} |
||||||
|
|
||||||
|
addAccounts (numberOfAccounts = 1) { |
||||||
|
if (!this.root) { |
||||||
|
this.initFromMnemonic(bip39.generateMnemonic()) |
||||||
|
} |
||||||
|
|
||||||
|
const oldLen = this.wallets.length |
||||||
|
const newWallets = [] |
||||||
|
for (let i = oldLen; i < numberOfAccounts + oldLen; i++) { |
||||||
|
const child = this.root.deriveChild(i) |
||||||
|
const wallet = child.getWallet() |
||||||
|
newWallets.push(wallet) |
||||||
|
this.wallets.push(wallet) |
||||||
|
} |
||||||
|
return newWallets.map(w => w.getAddress().toString('hex')) |
||||||
|
} |
||||||
|
|
||||||
|
getAccounts () { |
||||||
|
return this.wallets.map(w => w.getAddress().toString('hex')) |
||||||
|
} |
||||||
|
|
||||||
|
// tx is an instance of the ethereumjs-transaction class.
|
||||||
|
signTransaction (address, tx) { |
||||||
|
const wallet = this.getWalletForAccount(address) |
||||||
|
var privKey = wallet.getPrivateKey() |
||||||
|
tx.sign(privKey) |
||||||
|
return tx |
||||||
|
} |
||||||
|
|
||||||
|
// For eth_sign, we need to sign transactions:
|
||||||
|
signMessage (withAccount, data) { |
||||||
|
const wallet = this.getWalletForAccount(withAccount) |
||||||
|
const message = ethUtil.removeHexPrefix(data) |
||||||
|
var privKey = wallet.getPrivateKey() |
||||||
|
var msgSig = ethUtil.ecsign(new Buffer(message, 'hex'), privKey) |
||||||
|
var rawMsgSig = ethUtil.bufferToHex(sigUtil.concatSig(msgSig.v, msgSig.r, msgSig.s)) |
||||||
|
return rawMsgSig |
||||||
|
} |
||||||
|
|
||||||
|
getWalletForAccount (account) { |
||||||
|
return this.wallets.find((w) => { |
||||||
|
const address = w.getAddress().toString('hex') |
||||||
|
return ((address === account) || (sigUtil.normalize(address) === account)) |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,67 @@ |
|||||||
|
const EventEmitter = require('events').EventEmitter |
||||||
|
const Wallet = require('ethereumjs-wallet') |
||||||
|
const ethUtil = require('ethereumjs-util') |
||||||
|
const type = 'Simple Key Pair' |
||||||
|
const sigUtil = require('../lib/sig-util') |
||||||
|
|
||||||
|
module.exports = class SimpleKeyring extends EventEmitter { |
||||||
|
|
||||||
|
static type () { |
||||||
|
return type |
||||||
|
} |
||||||
|
|
||||||
|
constructor (opts) { |
||||||
|
super() |
||||||
|
this.type = type |
||||||
|
this.opts = opts || {} |
||||||
|
this.wallets = [] |
||||||
|
} |
||||||
|
|
||||||
|
serialize () { |
||||||
|
return this.wallets.map(w => w.getPrivateKey().toString('hex')) |
||||||
|
} |
||||||
|
|
||||||
|
deserialize (wallets = []) { |
||||||
|
this.wallets = wallets.map((w) => { |
||||||
|
var b = new Buffer(w, 'hex') |
||||||
|
const wallet = Wallet.fromPrivateKey(b) |
||||||
|
return wallet |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
addAccounts (n = 1) { |
||||||
|
var newWallets = [] |
||||||
|
for (var i = 0; i < n; i++) { |
||||||
|
newWallets.push(Wallet.generate()) |
||||||
|
} |
||||||
|
this.wallets = this.wallets.concat(newWallets) |
||||||
|
return newWallets.map(w => w.getAddress().toString('hex')) |
||||||
|
} |
||||||
|
|
||||||
|
getAccounts () { |
||||||
|
return this.wallets.map(w => w.getAddress().toString('hex')) |
||||||
|
} |
||||||
|
|
||||||
|
// tx is an instance of the ethereumjs-transaction class.
|
||||||
|
signTransaction (address, tx) { |
||||||
|
const wallet = this.getWalletForAccount(address) |
||||||
|
var privKey = wallet.getPrivateKey() |
||||||
|
tx.sign(privKey) |
||||||
|
return tx |
||||||
|
} |
||||||
|
|
||||||
|
// For eth_sign, we need to sign transactions:
|
||||||
|
signMessage (withAccount, data) { |
||||||
|
const wallet = this.getWalletForAccount(withAccount) |
||||||
|
const message = ethUtil.removeHexPrefix(data) |
||||||
|
var privKey = wallet.getPrivateKey() |
||||||
|
var msgSig = ethUtil.ecsign(new Buffer(message, 'hex'), privKey) |
||||||
|
var rawMsgSig = ethUtil.bufferToHex(sigUtil.concatSig(msgSig.v, msgSig.r, msgSig.s)) |
||||||
|
return rawMsgSig |
||||||
|
} |
||||||
|
|
||||||
|
getWalletForAccount (account) { |
||||||
|
return this.wallets.find(w => w.getAddress().toString('hex') === account) |
||||||
|
} |
||||||
|
|
||||||
|
} |
@ -0,0 +1,149 @@ |
|||||||
|
var ethUtil = require('ethereumjs-util') |
||||||
|
|
||||||
|
module.exports = { |
||||||
|
|
||||||
|
// Simple encryption methods:
|
||||||
|
encrypt, |
||||||
|
decrypt, |
||||||
|
|
||||||
|
// More advanced encryption methods:
|
||||||
|
keyFromPassword, |
||||||
|
encryptWithKey, |
||||||
|
decryptWithKey, |
||||||
|
|
||||||
|
// Buffer <-> String methods
|
||||||
|
convertArrayBufferViewtoString, |
||||||
|
convertStringToArrayBufferView, |
||||||
|
|
||||||
|
// Buffer <-> Hex string methods
|
||||||
|
serializeBufferForStorage, |
||||||
|
serializeBufferFromStorage, |
||||||
|
|
||||||
|
// Buffer <-> base64 string methods
|
||||||
|
encodeBufferToBase64, |
||||||
|
decodeBase64ToBuffer, |
||||||
|
|
||||||
|
generateSalt, |
||||||
|
} |
||||||
|
|
||||||
|
// Takes a Pojo, returns cypher text.
|
||||||
|
function encrypt (password, dataObj) { |
||||||
|
return keyFromPassword(password) |
||||||
|
.then(function (passwordDerivedKey) { |
||||||
|
return encryptWithKey(passwordDerivedKey, dataObj) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function encryptWithKey (key, dataObj) { |
||||||
|
var data = JSON.stringify(dataObj) |
||||||
|
var dataBuffer = convertStringToArrayBufferView(data) |
||||||
|
var vector = global.crypto.getRandomValues(new Uint8Array(16)) |
||||||
|
|
||||||
|
return global.crypto.subtle.encrypt({ |
||||||
|
name: 'AES-GCM', |
||||||
|
iv: vector, |
||||||
|
}, key, dataBuffer).then(function (buf) { |
||||||
|
var buffer = new Uint8Array(buf) |
||||||
|
var vectorStr = encodeBufferToBase64(vector) |
||||||
|
var vaultStr = encodeBufferToBase64(buffer) |
||||||
|
return `${vaultStr}\\${vectorStr}` |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// Takes encrypted text, returns the restored Pojo.
|
||||||
|
function decrypt (password, text) { |
||||||
|
return keyFromPassword(password) |
||||||
|
.then(function (key) { |
||||||
|
return decryptWithKey(key, text) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function decryptWithKey (key, text) { |
||||||
|
const parts = text.split('\\') |
||||||
|
const encryptedData = decodeBase64ToBuffer(parts[0]) |
||||||
|
const vector = decodeBase64ToBuffer(parts[1]) |
||||||
|
return crypto.subtle.decrypt({name: 'AES-GCM', iv: vector}, key, encryptedData) |
||||||
|
.then(function (result) { |
||||||
|
const decryptedData = new Uint8Array(result) |
||||||
|
const decryptedStr = convertArrayBufferViewtoString(decryptedData) |
||||||
|
const decryptedObj = JSON.parse(decryptedStr) |
||||||
|
return decryptedObj |
||||||
|
}) |
||||||
|
.catch(function (reason) { |
||||||
|
throw new Error('Incorrect password') |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function convertStringToArrayBufferView (str) { |
||||||
|
var bytes = new Uint8Array(str.length) |
||||||
|
for (var i = 0; i < str.length; i++) { |
||||||
|
bytes[i] = str.charCodeAt(i) |
||||||
|
} |
||||||
|
|
||||||
|
return bytes |
||||||
|
} |
||||||
|
|
||||||
|
function convertArrayBufferViewtoString (buffer) { |
||||||
|
var str = '' |
||||||
|
for (var i = 0; i < buffer.byteLength; i++) { |
||||||
|
str += String.fromCharCode(buffer[i]) |
||||||
|
} |
||||||
|
|
||||||
|
return str |
||||||
|
} |
||||||
|
|
||||||
|
function keyFromPassword (password) { |
||||||
|
var passBuffer = convertStringToArrayBufferView(password) |
||||||
|
return global.crypto.subtle.digest('SHA-256', passBuffer) |
||||||
|
.then(function (passHash) { |
||||||
|
return global.crypto.subtle.importKey('raw', passHash, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt']) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function serializeBufferFromStorage (str) { |
||||||
|
str = ethUtil.stripHexPrefix(str) |
||||||
|
var buf = new Uint8Array(str.length / 2) |
||||||
|
for (var i = 0; i < str.length; i += 2) { |
||||||
|
var seg = str.substr(i, 2) |
||||||
|
buf[i / 2] = parseInt(seg, 16) |
||||||
|
} |
||||||
|
return buf |
||||||
|
} |
||||||
|
|
||||||
|
// Should return a string, ready for storage, in hex format.
|
||||||
|
function serializeBufferForStorage (buffer) { |
||||||
|
var result = '0x' |
||||||
|
var len = buffer.length || buffer.byteLength |
||||||
|
for (var i = 0; i < len; i++) { |
||||||
|
result += unprefixedHex(buffer[i]) |
||||||
|
} |
||||||
|
return result |
||||||
|
} |
||||||
|
|
||||||
|
function unprefixedHex (num) { |
||||||
|
var hex = num.toString(16) |
||||||
|
while (hex.length < 2) { |
||||||
|
hex = '0' + hex |
||||||
|
} |
||||||
|
return hex |
||||||
|
} |
||||||
|
|
||||||
|
function encodeBufferToBase64 (buf) { |
||||||
|
var b64encoded = btoa(String.fromCharCode.apply(null, buf)) |
||||||
|
return b64encoded |
||||||
|
} |
||||||
|
|
||||||
|
function decodeBase64ToBuffer (base64) { |
||||||
|
var buf = new Uint8Array(atob(base64).split('') |
||||||
|
.map(function (c) { |
||||||
|
return c.charCodeAt(0) |
||||||
|
})) |
||||||
|
return buf |
||||||
|
} |
||||||
|
|
||||||
|
function generateSalt (byteCount = 32) { |
||||||
|
var view = new Uint8Array(byteCount) |
||||||
|
global.crypto.getRandomValues(view) |
||||||
|
var b64encoded = btoa(String.fromCharCode.apply(null, view)) |
||||||
|
return b64encoded |
||||||
|
} |
@ -0,0 +1,52 @@ |
|||||||
|
const IdentityStore = require('./idStore') |
||||||
|
|
||||||
|
|
||||||
|
module.exports = class IdentityStoreMigrator { |
||||||
|
|
||||||
|
constructor ({ configManager }) { |
||||||
|
this.configManager = configManager |
||||||
|
const hasOldVault = this.hasOldVault() |
||||||
|
if (!hasOldVault) { |
||||||
|
this.idStore = new IdentityStore({ configManager }) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
oldSeedForPassword (password) { |
||||||
|
const hasOldVault = this.hasOldVault() |
||||||
|
const configManager = this.configManager |
||||||
|
|
||||||
|
if (!this.idStore) { |
||||||
|
this.idStore = new IdentityStore({ configManager }) |
||||||
|
} |
||||||
|
|
||||||
|
if (!hasOldVault) { |
||||||
|
return Promise.resolve(null) |
||||||
|
} |
||||||
|
|
||||||
|
return new Promise((resolve, reject) => { |
||||||
|
this.idStore.submitPassword(password, (err) => { |
||||||
|
if (err) return reject(err) |
||||||
|
try { |
||||||
|
resolve(this.serializeVault()) |
||||||
|
} catch (e) { |
||||||
|
reject(e) |
||||||
|
} |
||||||
|
}) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
serializeVault () { |
||||||
|
const mnemonic = this.idStore._idmgmt.getSeed() |
||||||
|
const n = this.idStore._getAddresses().length |
||||||
|
|
||||||
|
return { |
||||||
|
type: 'HD Key Tree', |
||||||
|
data: { mnemonic, n }, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
hasOldVault () { |
||||||
|
const wallet = this.configManager.getWallet() |
||||||
|
return wallet |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,9 @@ |
|||||||
|
const MAX = 1000000000 |
||||||
|
|
||||||
|
let idCounter = Math.round( Math.random() * MAX ) |
||||||
|
function createRandomId() { |
||||||
|
idCounter = idCounter % MAX |
||||||
|
return idCounter++ |
||||||
|
} |
||||||
|
|
||||||
|
module.exports = createRandomId |
@ -0,0 +1,28 @@ |
|||||||
|
const ethUtil = require('ethereumjs-util') |
||||||
|
|
||||||
|
module.exports = { |
||||||
|
|
||||||
|
concatSig: function (v, r, s) { |
||||||
|
const rSig = ethUtil.fromSigned(r) |
||||||
|
const sSig = ethUtil.fromSigned(s) |
||||||
|
const vSig = ethUtil.bufferToInt(v) |
||||||
|
const rStr = padWithZeroes(ethUtil.toUnsigned(rSig).toString('hex'), 64) |
||||||
|
const sStr = padWithZeroes(ethUtil.toUnsigned(sSig).toString('hex'), 64) |
||||||
|
const vStr = ethUtil.stripHexPrefix(ethUtil.intToHex(vSig)) |
||||||
|
return ethUtil.addHexPrefix(rStr.concat(sStr, vStr)).toString('hex') |
||||||
|
}, |
||||||
|
|
||||||
|
normalize: function (address) { |
||||||
|
if (!address) return |
||||||
|
return ethUtil.addHexPrefix(address.toLowerCase()) |
||||||
|
}, |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
function padWithZeroes (number, length) { |
||||||
|
var myString = '' + number |
||||||
|
while (myString.length < length) { |
||||||
|
myString = '0' + myString |
||||||
|
} |
||||||
|
return myString |
||||||
|
} |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1 +1 @@ |
|||||||
{"metamask":{"isInitialized":true,"isUnlocked":true,"currentDomain":"example.com","rpcTarget":"https://rawtestrpc.metamask.io/","identities":{"0xfdea65c8e26263f6d9a1b5de9555d2931a33b825":{"name":"Wallet 1","address":"0xfdea65c8e26263f6d9a1b5de9555d2931a33b825","mayBeFauceting":false},"0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb":{"name":"Wallet 2","address":"0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb","mayBeFauceting":false},"0x2f8d4a878cfa04a6e60d46362f5644deab66572d":{"name":"Wallet 3","address":"0x2f8d4a878cfa04a6e60d46362f5644deab66572d","mayBeFauceting":false}},"unconfTxs":{"1467868023090690":{"id":1467868023090690,"txParams":{"data":"0xa9059cbb0000000000000000000000008deb4d106090c3eb8f1950f727e87c4f884fb06f0000000000000000000000000000000000000000000000000000000000000064","from":"0xfdea65c8e26263f6d9a1b5de9555d2931a33b825","value":"0x16345785d8a0000","to":"0xbeb0ed3034c4155f3d16a64a5c5e7c8d4ea9e9c9","origin":"MetaMask","metamaskId":1467868023090690,"metamaskNetworkId":"2"},"time":1467868023090,"status":"unconfirmed","containsDelegateCall":false}},"accounts":{"0xfdea65c8e26263f6d9a1b5de9555d2931a33b825":{"code":"0x","balance":"0x38326dc32cf80800","nonce":"0x10000c","address":"0xfdea65c8e26263f6d9a1b5de9555d2931a33b825"},"0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb":{"code":"0x","balance":"0x15e578bd8e9c8000","nonce":"0x100000","address":"0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb"},"0x2f8d4a878cfa04a6e60d46362f5644deab66572d":{"code":"0x","nonce":"0x100000","balance":"0x2386f26fc10000","address":"0x2f8d4a878cfa04a6e60d46362f5644deab66572d"}},"transactions":[],"selectedAddress":"0xfdea65c8e26263f6d9a1b5de9555d2931a33b825","network":"2","seedWords":null,"isConfirmed":true,"unconfMsgs":{},"messages":[],"provider":{"type":"testnet"},"selectedAccount":"0xfdea65c8e26263f6d9a1b5de9555d2931a33b825"},"appState":{"menuOpen":false,"currentView":{"name":"confTx","context":0},"accountDetail":{"subview":"transactions"},"currentDomain":"extensions","transForward":true,"isLoading":false,"warning":null},"identities":{}} |
{"metamask":{"isInitialized":true,"isUnlocked":true,"currentDomain":"example.com","rpcTarget":"https://rawtestrpc.metamask.io/","identities":{"0xfdea65c8e26263f6d9a1b5de9555d2931a33b825":{"name":"Wallet 1","address":"0xfdea65c8e26263f6d9a1b5de9555d2931a33b825","mayBeFauceting":false},"0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb":{"name":"Wallet 2","address":"0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb","mayBeFauceting":false},"0x2f8d4a878cfa04a6e60d46362f5644deab66572d":{"name":"Wallet 3","address":"0x2f8d4a878cfa04a6e60d46362f5644deab66572d","mayBeFauceting":false}},"unconfTxs":{"1467868023090690":{"id":1467868023090690,"txParams":{"data":"0xa9059cbb0000000000000000000000008deb4d106090c3eb8f1950f727e87c4f884fb06f0000000000000000000000000000000000000000000000000000000000000064","from":"0xfdea65c8e26263f6d9a1b5de9555d2931a33b825","value":"0x16345785d8a0000","to":"0xbeb0ed3034c4155f3d16a64a5c5e7c8d4ea9e9c9","origin":"MetaMask","metamaskId":1467868023090690,"metamaskNetworkId":"2"},"time":1467868023090,"status":"unconfirmed","containsDelegateCall":false}},"accounts":{"0xfdea65c8e26263f6d9a1b5de9555d2931a33b825":{"code":"0x","balance":"0x38326dc32cf80800","nonce":"0x10000c","address":"0xfdea65c8e26263f6d9a1b5de9555d2931a33b825"},"0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb":{"code":"0x","balance":"0x15e578bd8e9c8000","nonce":"0x100000","address":"0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb"},"0x2f8d4a878cfa04a6e60d46362f5644deab66572d":{"code":"0x","nonce":"0x100000","balance":"0x2386f26fc10000","address":"0x2f8d4a878cfa04a6e60d46362f5644deab66572d"}},"transactions":[],"selectedAddress":"0xfdea65c8e26263f6d9a1b5de9555d2931a33b825","network":"2","seedWords":null,"isDisclaimerConfirmed":true,"unconfMsgs":{},"messages":[],"provider":{"type":"testnet"},"selectedAccount":"0xfdea65c8e26263f6d9a1b5de9555d2931a33b825"},"appState":{"menuOpen":false,"currentView":{"name":"confTx","context":0},"accountDetail":{"subview":"transactions"},"currentDomain":"extensions","transForward":true,"isLoading":false,"warning":null},"identities":{}} |
||||||
|
File diff suppressed because one or more lines are too long
@ -0,0 +1,188 @@ |
|||||||
|
https://hackmd.io/JwIwDMDGKQZgtAFgKZjEgbARhPAhgKxZbwAcA7LAWOQCaKEgFA==?edit |
||||||
|
|
||||||
|
Subscribablez(initState) |
||||||
|
.subscribe() |
||||||
|
.emitUpdate(newState) |
||||||
|
//.getState() |
||||||
|
|
||||||
|
|
||||||
|
var initState = fromDisk() |
||||||
|
ReduxStore(reducer, initState) |
||||||
|
.reduce(action) -> .emitUpdate() |
||||||
|
|
||||||
|
ReduxStore.subscribe(toDisk) |
||||||
|
|
||||||
|
|
||||||
|
### KeyChainManager / idStore 2.0 (maybe just in MetaMaskController) |
||||||
|
keychains: [] |
||||||
|
getAllAccounts(cb) |
||||||
|
getAllKeychainViewStates(cb) -> returns [ KeyChainViewState] |
||||||
|
|
||||||
|
#### Old idStore external methods, for feature parity: |
||||||
|
|
||||||
|
- init(configManager) |
||||||
|
- setStore(ethStore) |
||||||
|
- getState() |
||||||
|
- getSelectedAddres() |
||||||
|
- setSelectedAddress() |
||||||
|
- createNewVault() |
||||||
|
- recoverFromSeed() |
||||||
|
- submitPassword() |
||||||
|
- approveTransaction() |
||||||
|
- cancelTransaction() |
||||||
|
- addUnconfirmedMessage(msgParams, cb) |
||||||
|
- signMessage() |
||||||
|
- cancelMessage() |
||||||
|
- setLocked() |
||||||
|
- clearSeedWordCache() |
||||||
|
- exportAccount() |
||||||
|
- revealAccount() |
||||||
|
- saveAccountLabel() |
||||||
|
- tryPassword() |
||||||
|
- recoverSeed() |
||||||
|
- getNetwork() |
||||||
|
|
||||||
|
##### Of those methods |
||||||
|
|
||||||
|
Where they should end up: |
||||||
|
|
||||||
|
##### MetaMaskController |
||||||
|
|
||||||
|
- getNetwork() |
||||||
|
|
||||||
|
##### KeyChainManager |
||||||
|
|
||||||
|
- init(configManager) |
||||||
|
- setStore(ethStore) |
||||||
|
- getState() // Deprecate for unidirectional flow |
||||||
|
- on('update', cb) |
||||||
|
- createNewVault(password) |
||||||
|
- getSelectedAddres() |
||||||
|
- setSelectedAddress() |
||||||
|
- submitPassword() |
||||||
|
- tryPassword() |
||||||
|
- approveTransaction() |
||||||
|
- cancelTransaction() |
||||||
|
- signMessage() |
||||||
|
- cancelMessage() |
||||||
|
- setLocked() |
||||||
|
- exportAccount() |
||||||
|
|
||||||
|
##### Bip44 KeyChain |
||||||
|
|
||||||
|
- getState() // Deprecate for unidirectional flow |
||||||
|
- on('update', cb) |
||||||
|
|
||||||
|
If we adopt a ReactStore style unidirectional action dispatching data flow, these methods will be unified under a `dispatch` method, and rather than having a cb will emit an update to the UI: |
||||||
|
|
||||||
|
- createNewKeyChain(entropy) |
||||||
|
- recoverFromSeed() |
||||||
|
- approveTransaction() |
||||||
|
- signMessage() |
||||||
|
- clearSeedWordCache() |
||||||
|
- exportAccount() |
||||||
|
- revealAccount() |
||||||
|
- saveAccountLabel() |
||||||
|
- recoverSeed() |
||||||
|
|
||||||
|
Additional methods, new to this: |
||||||
|
- serialize() |
||||||
|
- Returns pojo with optional `secret` key whose contents will be encrypted with the users' password and salt when written to disk. |
||||||
|
- The isolation of secrets is to preserve performance when decrypting user data. |
||||||
|
- deserialize(pojo) |
||||||
|
|
||||||
|
### KeyChain (ReduxStore?) |
||||||
|
// attributes |
||||||
|
@name |
||||||
|
|
||||||
|
signTx(txParams, cb) |
||||||
|
signMsg(msg, cb) |
||||||
|
|
||||||
|
getAddressList(cb) |
||||||
|
|
||||||
|
getViewState(cb) -> returns KeyChainViewState |
||||||
|
|
||||||
|
serialize(cb) -> obj |
||||||
|
deserialize(obj) |
||||||
|
|
||||||
|
dispatch({ type: <str>, value: <pojo> }) |
||||||
|
|
||||||
|
|
||||||
|
### KeyChainViewState |
||||||
|
// The serialized, renderable keychain data |
||||||
|
accountList: [], |
||||||
|
typeName: 'uPort', |
||||||
|
iconAddress: 'uport.gif', |
||||||
|
internal: {} // Subclass-defined metadata |
||||||
|
|
||||||
|
### KeyChainReactComponent |
||||||
|
// takes a KeyChainViewState |
||||||
|
|
||||||
|
// Subclasses of this: |
||||||
|
- KeyChainListItemComponent |
||||||
|
- KeyChainInitComponent - Maybe part of the List Item |
||||||
|
- KeyChainAccountHeaderComponent |
||||||
|
- KeyChainConfirmationComponent |
||||||
|
// Account list item, tx confirmation extra data (like a QR code), |
||||||
|
// Maybe an options screen, init screen, |
||||||
|
|
||||||
|
how to send actions? |
||||||
|
emitAction(keychains.<id>.didInit) |
||||||
|
|
||||||
|
|
||||||
|
gimmeRemoteKeychain((err, remoteKeychain)=> |
||||||
|
|
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
KeyChainReactComponent({ |
||||||
|
keychain |
||||||
|
}) |
||||||
|
|
||||||
|
Keychain: |
||||||
|
methods:{}, |
||||||
|
cachedAccountList: [], |
||||||
|
name: '', |
||||||
|
|
||||||
|
|
||||||
|
CoinbaseKeychain |
||||||
|
getAccountList |
||||||
|
|
||||||
|
|
||||||
|
CoinbaseKeychainComponent |
||||||
|
isLoading = true |
||||||
|
keychain.getAccountList(()=>{ |
||||||
|
isLoading=false |
||||||
|
accountList=accounts |
||||||
|
}) |
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
KeyChainViewState { |
||||||
|
attributes: { |
||||||
|
//mandatory: |
||||||
|
accountList: [], |
||||||
|
typeName: 'uPort', |
||||||
|
iconAddress: 'uport.gif', |
||||||
|
|
||||||
|
internal: { |
||||||
|
// keychain-specific metadata |
||||||
|
proxyAddresses: { |
||||||
|
0xReal: '0xProxy' |
||||||
|
} |
||||||
|
}, |
||||||
|
}, |
||||||
|
methods: { |
||||||
|
// arbitrary, internal |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
## A note on the security of arbitrary action dispatchers |
||||||
|
|
||||||
|
Since keychains will be dispatching actions that are then passed through the background process to be routed, we should not trust or require them to include their own keychain ID as a prefix to their action, but we should tack it on ourselves, so that no action dispatched by a KeyChainComponent ever reaches any KeyChain other than its own. |
||||||
|
|
@ -0,0 +1,21 @@ |
|||||||
|
var fs = require('fs') |
||||||
|
var path = require('path') |
||||||
|
var browserify = require('browserify'); |
||||||
|
var tests = fs.readdirSync(path.join(__dirname, 'lib')) |
||||||
|
var bundlePath = path.join(__dirname, 'bundle.js') |
||||||
|
|
||||||
|
var b = browserify(); |
||||||
|
|
||||||
|
// Remove old bundle
|
||||||
|
try { |
||||||
|
fs.unlinkSync(bundlePath) |
||||||
|
} catch (e) {} |
||||||
|
|
||||||
|
var writeStream = fs.createWriteStream(bundlePath) |
||||||
|
|
||||||
|
tests.forEach(function(fileName) { |
||||||
|
b.add(path.join(__dirname, 'lib', fileName)) |
||||||
|
}) |
||||||
|
|
||||||
|
b.bundle().pipe(writeStream); |
||||||
|
|
@ -0,0 +1,67 @@ |
|||||||
|
var encryptor = require('../../../app/scripts/lib/encryptor') |
||||||
|
|
||||||
|
QUnit.test('encryptor:serializeBufferForStorage', function (assert) { |
||||||
|
assert.expect(1) |
||||||
|
var buf = new Buffer(2) |
||||||
|
buf[0] = 16 |
||||||
|
buf[1] = 1 |
||||||
|
|
||||||
|
var output = encryptor.serializeBufferForStorage(buf) |
||||||
|
|
||||||
|
var expect = '0x1001' |
||||||
|
assert.equal(expect, output) |
||||||
|
}) |
||||||
|
|
||||||
|
QUnit.test('encryptor:serializeBufferFromStorage', function (assert) { |
||||||
|
assert.expect(2) |
||||||
|
var input = '0x1001' |
||||||
|
var output = encryptor.serializeBufferFromStorage(input) |
||||||
|
|
||||||
|
assert.equal(output[0], 16) |
||||||
|
assert.equal(output[1], 1) |
||||||
|
}) |
||||||
|
|
||||||
|
QUnit.test('encryptor:encrypt & decrypt', function(assert) { |
||||||
|
var done = assert.async(); |
||||||
|
var password, data, encrypted |
||||||
|
|
||||||
|
password = 'a sample passw0rd' |
||||||
|
data = { foo: 'data to encrypt' } |
||||||
|
|
||||||
|
encryptor.encrypt(password, data) |
||||||
|
.then(function(encryptedStr) { |
||||||
|
assert.equal(typeof encryptedStr, 'string', 'returns a string') |
||||||
|
return encryptor.decrypt(password, encryptedStr) |
||||||
|
}) |
||||||
|
.then(function (decryptedObj) { |
||||||
|
assert.deepEqual(decryptedObj, data, 'decrypted what was encrypted') |
||||||
|
done() |
||||||
|
}) |
||||||
|
.catch(function(reason) { |
||||||
|
assert.ifError(reason, 'threw an error') |
||||||
|
done(reason) |
||||||
|
}) |
||||||
|
|
||||||
|
}) |
||||||
|
|
||||||
|
QUnit.test('encryptor:encrypt & decrypt with wrong password', function(assert) { |
||||||
|
var done = assert.async(); |
||||||
|
var password, data, encrypted, wrongPassword |
||||||
|
|
||||||
|
password = 'a sample passw0rd' |
||||||
|
wrongPassword = 'a wrong password' |
||||||
|
data = { foo: 'data to encrypt' } |
||||||
|
|
||||||
|
encryptor.encrypt(password, data) |
||||||
|
.then(function(encryptedStr) { |
||||||
|
assert.equal(typeof encryptedStr, 'string', 'returns a string') |
||||||
|
return encryptor.decrypt(wrongPassword, encryptedStr) |
||||||
|
}) |
||||||
|
.then(function (decryptedObj) { |
||||||
|
assert.equal(!decryptedObj, true, 'Wrong password should not decrypt') |
||||||
|
done() |
||||||
|
}) |
||||||
|
.catch(function(reason) { |
||||||
|
done() |
||||||
|
}) |
||||||
|
}) |
@ -0,0 +1,15 @@ |
|||||||
|
QUnit.test('agree to terms', function (assert) { |
||||||
|
var done = assert.async() |
||||||
|
let app |
||||||
|
|
||||||
|
wait().then(function() { |
||||||
|
app = $('iframe').contents().find('#app-content .mock-app-root') |
||||||
|
app.find('.markdown').prop('scrollTop', 100000000) |
||||||
|
return wait() |
||||||
|
}).then(function() { |
||||||
|
var title = app.find('h1').text() |
||||||
|
assert.equal(title, 'MetaMask', 'title screen') |
||||||
|
|
||||||
|
done() |
||||||
|
}) |
||||||
|
}) |
@ -1,24 +0,0 @@ |
|||||||
QUnit.test('agree to terms', function (assert) { |
|
||||||
var done = assert.async() |
|
||||||
|
|
||||||
// Select the mock app root
|
|
||||||
var app = $('iframe').contents().find('#app-content .mock-app-root') |
|
||||||
|
|
||||||
app.find('.markdown').prop('scrollTop', 100000000) |
|
||||||
|
|
||||||
wait().then(function() { |
|
||||||
app.find('button').click() |
|
||||||
}).then(function() { |
|
||||||
return wait() |
|
||||||
}).then(function() { |
|
||||||
var title = app.find('h1').text() |
|
||||||
assert.equal(title, 'MetaMask', 'title screen') |
|
||||||
|
|
||||||
var buttons = app.find('button') |
|
||||||
assert.equal(buttons.length, 2, 'two buttons: create and restore') |
|
||||||
|
|
||||||
done() |
|
||||||
}) |
|
||||||
|
|
||||||
// Wait for view to transition:
|
|
||||||
}) |
|
@ -0,0 +1,32 @@ |
|||||||
|
var mockHex = '0xabcdef0123456789' |
||||||
|
var mockKey = new Buffer(32) |
||||||
|
let cacheVal |
||||||
|
|
||||||
|
module.exports = { |
||||||
|
|
||||||
|
encrypt(password, dataObj) { |
||||||
|
cacheVal = dataObj |
||||||
|
return Promise.resolve(mockHex) |
||||||
|
}, |
||||||
|
|
||||||
|
decrypt(password, text) { |
||||||
|
return Promise.resolve(cacheVal || {}) |
||||||
|
}, |
||||||
|
|
||||||
|
encryptWithKey(key, dataObj) { |
||||||
|
return this.encrypt(key, dataObj) |
||||||
|
}, |
||||||
|
|
||||||
|
decryptWithKey(key, text) { |
||||||
|
return this.decrypt(key, text) |
||||||
|
}, |
||||||
|
|
||||||
|
keyFromPassword(password) { |
||||||
|
return Promise.resolve(mockKey) |
||||||
|
}, |
||||||
|
|
||||||
|
generateSalt() { |
||||||
|
return 'WHADDASALT!' |
||||||
|
}, |
||||||
|
|
||||||
|
} |
@ -0,0 +1,38 @@ |
|||||||
|
var fakeWallet = { |
||||||
|
privKey: '0x123456788890abcdef', |
||||||
|
address: '0xfedcba0987654321', |
||||||
|
} |
||||||
|
const type = 'Simple Key Pair' |
||||||
|
|
||||||
|
module.exports = class MockSimpleKeychain { |
||||||
|
|
||||||
|
static type() { return type } |
||||||
|
|
||||||
|
constructor(opts) { |
||||||
|
this.type = type |
||||||
|
this.opts = opts || {} |
||||||
|
this.wallets = [] |
||||||
|
} |
||||||
|
|
||||||
|
serialize() { |
||||||
|
return [ fakeWallet.privKey ] |
||||||
|
} |
||||||
|
|
||||||
|
deserialize(data) { |
||||||
|
if (!Array.isArray(data)) { |
||||||
|
throw new Error('Simple keychain deserialize requires a privKey array.') |
||||||
|
} |
||||||
|
this.wallets = [ fakeWallet ] |
||||||
|
} |
||||||
|
|
||||||
|
addAccounts(n = 1) { |
||||||
|
for(var i = 0; i < n; i++) { |
||||||
|
this.wallets.push(fakeWallet) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
getAccounts() { |
||||||
|
return this.wallets.map(w => w.address) |
||||||
|
} |
||||||
|
|
||||||
|
} |
@ -1,60 +0,0 @@ |
|||||||
var jsdom = require('mocha-jsdom') |
|
||||||
var assert = require('assert') |
|
||||||
var freeze = require('deep-freeze-strict') |
|
||||||
var path = require('path') |
|
||||||
var sinon = require('sinon') |
|
||||||
|
|
||||||
var actions = require(path.join(__dirname, '..', '..', '..', 'ui', 'app', 'actions.js')) |
|
||||||
var reducers = require(path.join(__dirname, '..', '..', '..', 'ui', 'app', 'reducers.js')) |
|
||||||
|
|
||||||
describe('#recoverFromSeed(password, seed)', function() { |
|
||||||
|
|
||||||
beforeEach(function() { |
|
||||||
// sinon allows stubbing methods that are easily verified
|
|
||||||
this.sinon = sinon.sandbox.create() |
|
||||||
}) |
|
||||||
|
|
||||||
afterEach(function() { |
|
||||||
// sinon requires cleanup otherwise it will overwrite context
|
|
||||||
this.sinon.restore() |
|
||||||
}) |
|
||||||
|
|
||||||
// stub out account manager
|
|
||||||
actions._setAccountManager({ |
|
||||||
recoverFromSeed(pw, seed, cb) { |
|
||||||
cb(null, { |
|
||||||
identities: { |
|
||||||
foo: 'bar' |
|
||||||
} |
|
||||||
}) |
|
||||||
}, |
|
||||||
}) |
|
||||||
|
|
||||||
it('sets metamask.isUnlocked to true', function() { |
|
||||||
var initialState = { |
|
||||||
metamask: { |
|
||||||
isUnlocked: false, |
|
||||||
isInitialized: false, |
|
||||||
} |
|
||||||
} |
|
||||||
freeze(initialState) |
|
||||||
|
|
||||||
const restorePhrase = 'invite heavy among daring outdoor dice jelly coil stable note seat vicious' |
|
||||||
const password = 'foo' |
|
||||||
const dispatchFunc = actions.recoverFromSeed(password, restorePhrase) |
|
||||||
|
|
||||||
var dispatchStub = this.sinon.stub() |
|
||||||
dispatchStub.withArgs({ TYPE: actions.unlockMetamask() }).onCall(0) |
|
||||||
dispatchStub.withArgs({ TYPE: actions.showAccountsPage() }).onCall(1) |
|
||||||
|
|
||||||
var action |
|
||||||
var resultingState = initialState |
|
||||||
dispatchFunc((newAction) => { |
|
||||||
action = newAction |
|
||||||
resultingState = reducers(resultingState, action) |
|
||||||
}) |
|
||||||
|
|
||||||
assert.equal(resultingState.metamask.isUnlocked, true, 'was unlocked') |
|
||||||
assert.equal(resultingState.metamask.isInitialized, true, 'was initialized') |
|
||||||
}); |
|
||||||
}); |
|
@ -0,0 +1,160 @@ |
|||||||
|
const async = require('async') |
||||||
|
const assert = require('assert') |
||||||
|
const ethUtil = require('ethereumjs-util') |
||||||
|
const BN = ethUtil.BN |
||||||
|
const ConfigManager = require('../../app/scripts/lib/config-manager') |
||||||
|
const delegateCallCode = require('../lib/example-code.json').delegateCallCode |
||||||
|
|
||||||
|
// The old way:
|
||||||
|
const IdentityStore = require('../../app/scripts/lib/idStore') |
||||||
|
const STORAGE_KEY = 'metamask-config' |
||||||
|
const extend = require('xtend') |
||||||
|
|
||||||
|
// The new ways:
|
||||||
|
var KeyringController = require('../../app/scripts/keyring-controller') |
||||||
|
const mockEncryptor = require('../lib/mock-encryptor') |
||||||
|
const MockSimpleKeychain = require('../lib/mock-simple-keychain') |
||||||
|
const sinon = require('sinon') |
||||||
|
|
||||||
|
const mockVault = { |
||||||
|
seed: 'picnic injury awful upper eagle junk alert toss flower renew silly vague', |
||||||
|
account: '0x5d8de92c205279c10e5669f797b853ccef4f739a', |
||||||
|
} |
||||||
|
|
||||||
|
describe('IdentityStore to KeyringController migration', function() { |
||||||
|
|
||||||
|
// The stars of the show:
|
||||||
|
let idStore, keyringController, seedWords, configManager |
||||||
|
|
||||||
|
let password = 'password123' |
||||||
|
let entropy = 'entripppppyy duuude' |
||||||
|
let accounts = [] |
||||||
|
let newAccounts = [] |
||||||
|
let originalKeystore |
||||||
|
|
||||||
|
// This is a lot of setup, I know!
|
||||||
|
// We have to create an old style vault, populate it,
|
||||||
|
// and THEN create a new one, before we can run tests on it.
|
||||||
|
beforeEach(function(done) { |
||||||
|
this.sinon = sinon.sandbox.create() |
||||||
|
window.localStorage = {} // Hacking localStorage support into JSDom
|
||||||
|
configManager = new ConfigManager({ |
||||||
|
loadData, |
||||||
|
setData: (d) => { window.localStorage = d } |
||||||
|
}) |
||||||
|
|
||||||
|
|
||||||
|
idStore = new IdentityStore({ |
||||||
|
configManager: configManager, |
||||||
|
ethStore: { |
||||||
|
addAccount(acct) { accounts.push(ethUtil.addHexPrefix(acct)) }, |
||||||
|
del(acct) { delete accounts[acct] }, |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
idStore._createVault(password, mockVault.seed, null, (err) => { |
||||||
|
assert.ifError(err, 'createNewVault threw error') |
||||||
|
originalKeystore = idStore._idmgmt.keyStore |
||||||
|
|
||||||
|
idStore.setLocked((err) => { |
||||||
|
assert.ifError(err, 'createNewVault threw error') |
||||||
|
keyringController = new KeyringController({ |
||||||
|
configManager, |
||||||
|
ethStore: { |
||||||
|
addAccount(acct) { newAccounts.push(ethUtil.addHexPrefix(acct)) }, |
||||||
|
del(acct) { delete newAccounts[acct] }, |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
// Stub out the browser crypto for a mock encryptor.
|
||||||
|
// Browser crypto is tested in the integration test suite.
|
||||||
|
keyringController.encryptor = mockEncryptor |
||||||
|
done() |
||||||
|
}) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('entering a password', function() { |
||||||
|
it('should identify an old wallet as an initialized keyring', function() { |
||||||
|
keyringController.configManager.setWallet('something') |
||||||
|
const state = keyringController.getState() |
||||||
|
assert(state.isInitialized, 'old vault counted as initialized.') |
||||||
|
}) |
||||||
|
|
||||||
|
/* |
||||||
|
it('should use the password to migrate the old vault', function(done) { |
||||||
|
this.timeout(5000) |
||||||
|
console.log('calling submitPassword') |
||||||
|
console.dir(keyringController) |
||||||
|
keyringController.submitPassword(password, function (err, state) { |
||||||
|
assert.ifError(err, 'submitPassword threw error') |
||||||
|
|
||||||
|
function log(str, dat) { console.log(str + ': ' + JSON.stringify(dat)) } |
||||||
|
|
||||||
|
let newAccounts = keyringController.getAccounts() |
||||||
|
log('new accounts: ', newAccounts) |
||||||
|
|
||||||
|
let newAccount = ethUtil.addHexPrefix(newAccounts[0]) |
||||||
|
assert.equal(ethUtil.addHexPrefix(newAccount), mockVault.account, 'restored the correct account') |
||||||
|
const newSeed = keyringController.keyrings[0].mnemonic |
||||||
|
log('keyringController keyrings', keyringController.keyrings) |
||||||
|
assert.equal(newSeed, mockVault.seed, 'seed phrase transferred.') |
||||||
|
|
||||||
|
assert(configManager.getVault(), 'new type of vault is persisted') |
||||||
|
done() |
||||||
|
}) |
||||||
|
}) |
||||||
|
*/ |
||||||
|
|
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
function loadData () { |
||||||
|
var oldData = getOldStyleData() |
||||||
|
var newData |
||||||
|
try { |
||||||
|
newData = JSON.parse(window.localStorage[STORAGE_KEY]) |
||||||
|
} catch (e) {} |
||||||
|
|
||||||
|
var data = extend({ |
||||||
|
meta: { |
||||||
|
version: 0, |
||||||
|
}, |
||||||
|
data: { |
||||||
|
config: { |
||||||
|
provider: { |
||||||
|
type: 'testnet', |
||||||
|
}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
}, oldData || null, newData || null) |
||||||
|
return data |
||||||
|
} |
||||||
|
|
||||||
|
function setData (data) { |
||||||
|
window.localStorage[STORAGE_KEY] = JSON.stringify(data) |
||||||
|
} |
||||||
|
|
||||||
|
function getOldStyleData () { |
||||||
|
var config, wallet, seedWords |
||||||
|
|
||||||
|
var result = { |
||||||
|
meta: { version: 0 }, |
||||||
|
data: {}, |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
config = JSON.parse(window.localStorage['config']) |
||||||
|
result.data.config = config |
||||||
|
} catch (e) {} |
||||||
|
try { |
||||||
|
wallet = JSON.parse(window.localStorage['lightwallet']) |
||||||
|
result.data.wallet = wallet |
||||||
|
} catch (e) {} |
||||||
|
try { |
||||||
|
seedWords = window.localStorage['seedWords'] |
||||||
|
result.data.seedWords = seedWords |
||||||
|
} catch (e) {} |
||||||
|
|
||||||
|
return result |
||||||
|
} |
@ -0,0 +1,172 @@ |
|||||||
|
var assert = require('assert') |
||||||
|
var KeyringController = require('../../app/scripts/keyring-controller') |
||||||
|
var configManagerGen = require('../lib/mock-config-manager') |
||||||
|
const ethUtil = require('ethereumjs-util') |
||||||
|
const BN = ethUtil.BN |
||||||
|
const async = require('async') |
||||||
|
const mockEncryptor = require('../lib/mock-encryptor') |
||||||
|
const MockSimpleKeychain = require('../lib/mock-simple-keychain') |
||||||
|
const sinon = require('sinon') |
||||||
|
|
||||||
|
describe('KeyringController', function() { |
||||||
|
|
||||||
|
let keyringController, state |
||||||
|
let password = 'password123' |
||||||
|
let entropy = 'entripppppyy duuude' |
||||||
|
let seedWords = 'puzzle seed penalty soldier say clay field arctic metal hen cage runway' |
||||||
|
let addresses = ['eF35cA8EbB9669A35c31b5F6f249A9941a812AC1'.toLowerCase()] |
||||||
|
let accounts = [] |
||||||
|
let originalKeystore |
||||||
|
|
||||||
|
beforeEach(function(done) { |
||||||
|
this.sinon = sinon.sandbox.create() |
||||||
|
window.localStorage = {} // Hacking localStorage support into JSDom
|
||||||
|
|
||||||
|
keyringController = new KeyringController({ |
||||||
|
configManager: configManagerGen(), |
||||||
|
ethStore: { |
||||||
|
addAccount(acct) { accounts.push(ethUtil.addHexPrefix(acct)) }, |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
// Stub out the browser crypto for a mock encryptor.
|
||||||
|
// Browser crypto is tested in the integration test suite.
|
||||||
|
keyringController.encryptor = mockEncryptor |
||||||
|
|
||||||
|
keyringController.createNewVaultAndKeychain(password, null, function (err, newState) { |
||||||
|
assert.ifError(err) |
||||||
|
state = newState |
||||||
|
done() |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
afterEach(function() { |
||||||
|
// Cleanup mocks
|
||||||
|
this.sinon.restore() |
||||||
|
}) |
||||||
|
|
||||||
|
describe('#createNewVaultAndKeychain', function () { |
||||||
|
this.timeout(10000) |
||||||
|
|
||||||
|
it('should set a vault on the configManager', function(done) { |
||||||
|
keyringController.configManager.setVault(null) |
||||||
|
assert(!keyringController.configManager.getVault(), 'no previous vault') |
||||||
|
keyringController.createNewVaultAndKeychain(password, null, (err, state) => { |
||||||
|
assert.ifError(err) |
||||||
|
const vault = keyringController.configManager.getVault() |
||||||
|
assert(vault, 'vault created') |
||||||
|
done() |
||||||
|
}) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('#restoreKeyring', function() { |
||||||
|
|
||||||
|
it(`should pass a keyring's serialized data back to the correct type.`, function() { |
||||||
|
const mockSerialized = { |
||||||
|
type: 'HD Key Tree', |
||||||
|
data: { |
||||||
|
mnemonic: seedWords, |
||||||
|
numberOfAccounts: 1, |
||||||
|
} |
||||||
|
} |
||||||
|
const mock = this.sinon.mock(keyringController) |
||||||
|
|
||||||
|
mock.expects('getBalanceAndNickname') |
||||||
|
.exactly(1) |
||||||
|
|
||||||
|
var keyring = keyringController.restoreKeyring(mockSerialized) |
||||||
|
assert.equal(keyring.wallets.length, 1, 'one wallet restored') |
||||||
|
assert.equal(keyring.getAccounts()[0], addresses[0]) |
||||||
|
mock.verify() |
||||||
|
}) |
||||||
|
|
||||||
|
}) |
||||||
|
|
||||||
|
describe('#migrateAndGetKey', function() { |
||||||
|
it('should return the key for that password', function(done) { |
||||||
|
keyringController.migrateAndGetKey(password) |
||||||
|
.then((key) => { |
||||||
|
assert(key, 'a key is returned') |
||||||
|
done() |
||||||
|
}) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('#createNickname', function() { |
||||||
|
it('should add the address to the identities hash', function() { |
||||||
|
const fakeAddress = '0x12345678' |
||||||
|
keyringController.createNickname(fakeAddress) |
||||||
|
const identities = keyringController.identities |
||||||
|
const identity = identities[fakeAddress] |
||||||
|
assert.equal(identity.address, fakeAddress) |
||||||
|
|
||||||
|
const nick = keyringController.configManager.nicknameForWallet(fakeAddress) |
||||||
|
assert.equal(typeof nick, 'string') |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('#saveAccountLabel', function() { |
||||||
|
it ('sets the nickname', function() { |
||||||
|
const account = addresses[0] |
||||||
|
var nick = 'Test nickname' |
||||||
|
keyringController.identities[ethUtil.addHexPrefix(account)] = {} |
||||||
|
const label = keyringController.saveAccountLabel(account, nick) |
||||||
|
assert.equal(label, nick) |
||||||
|
|
||||||
|
const persisted = keyringController.configManager.nicknameForWallet(account) |
||||||
|
assert.equal(persisted, nick) |
||||||
|
}) |
||||||
|
|
||||||
|
this.timeout(10000) |
||||||
|
it('retrieves the persisted nickname', function(done) { |
||||||
|
const account = addresses[0] |
||||||
|
var nick = 'Test nickname' |
||||||
|
keyringController.configManager.setNicknameForWallet(account, nick) |
||||||
|
console.log('calling to restore') |
||||||
|
keyringController.createNewVaultAndRestore(password, seedWords, (err, state) => { |
||||||
|
console.dir({err}) |
||||||
|
assert.ifError(err) |
||||||
|
|
||||||
|
const identity = keyringController.identities['0x' + account] |
||||||
|
assert.equal(identity.name, nick) |
||||||
|
|
||||||
|
assert(accounts) |
||||||
|
done() |
||||||
|
}) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('#getAccounts', function() { |
||||||
|
it('returns the result of getAccounts for each keyring', function() { |
||||||
|
keyringController.keyrings = [ |
||||||
|
{ getAccounts() { return [1,2,3] } }, |
||||||
|
{ getAccounts() { return [4,5,6] } }, |
||||||
|
] |
||||||
|
|
||||||
|
const result = keyringController.getAccounts() |
||||||
|
assert.deepEqual(result, [1,2,3,4,5,6]) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('#addGasBuffer', function() { |
||||||
|
it('adds 100k gas buffer to estimates', function() { |
||||||
|
|
||||||
|
const gas = '0x04ee59' // Actual estimated gas example
|
||||||
|
const tooBigOutput = '0x80674f9' // Actual bad output
|
||||||
|
const bnGas = new BN(ethUtil.stripHexPrefix(gas), 16) |
||||||
|
const correctBuffer = new BN('100000', 10) |
||||||
|
const correct = bnGas.add(correctBuffer) |
||||||
|
|
||||||
|
const tooBig = new BN(tooBigOutput, 16) |
||||||
|
const result = keyringController.addGasBuffer(gas) |
||||||
|
const bnResult = new BN(ethUtil.stripHexPrefix(result), 16) |
||||||
|
|
||||||
|
assert.equal(result.indexOf('0x'), 0, 'included hex prefix') |
||||||
|
assert(bnResult.gt(bnGas), 'Estimate increased in value.') |
||||||
|
assert.equal(bnResult.sub(bnGas).toString(10), '100000', 'added 100k gas') |
||||||
|
assert.equal(result, '0x' + correct.toString(16), 'Added the right amount') |
||||||
|
assert.notEqual(result, tooBigOutput, 'not that bad estimate') |
||||||
|
}) |
||||||
|
}) |
||||||
|
}) |
@ -0,0 +1,108 @@ |
|||||||
|
const assert = require('assert') |
||||||
|
const extend = require('xtend') |
||||||
|
const HdKeyring = require('../../../app/scripts/keyrings/hd') |
||||||
|
|
||||||
|
// Sample account:
|
||||||
|
const privKeyHex = 'b8a9c05beeedb25df85f8d641538cbffedf67216048de9c678ee26260eb91952' |
||||||
|
|
||||||
|
const sampleMnemonic = 'finish oppose decorate face calm tragic certain desk hour urge dinosaur mango' |
||||||
|
const firstAcct = '1c96099350f13d558464ec79b9be4445aa0ef579' |
||||||
|
const secondAcct = '1b00aed43a693f3a957f9feb5cc08afa031e37a0' |
||||||
|
|
||||||
|
describe('hd-keyring', function() { |
||||||
|
|
||||||
|
let keyring |
||||||
|
beforeEach(function() { |
||||||
|
keyring = new HdKeyring() |
||||||
|
}) |
||||||
|
|
||||||
|
describe('constructor', function() { |
||||||
|
keyring = new HdKeyring({ |
||||||
|
mnemonic: sampleMnemonic, |
||||||
|
numberOfAccounts: 2, |
||||||
|
}) |
||||||
|
|
||||||
|
const accounts = keyring.getAccounts() |
||||||
|
assert.equal(accounts[0], firstAcct) |
||||||
|
assert.equal(accounts[1], secondAcct) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('Keyring.type()', function() { |
||||||
|
it('is a class method that returns the type string.', function() { |
||||||
|
const type = HdKeyring.type() |
||||||
|
assert.equal(typeof type, 'string') |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('#type', function() { |
||||||
|
it('returns the correct value', function() { |
||||||
|
const type = keyring.type |
||||||
|
const correct = HdKeyring.type() |
||||||
|
assert.equal(type, correct) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('#serialize empty wallets.', function() { |
||||||
|
it('serializes a new mnemonic', function() { |
||||||
|
const output = keyring.serialize() |
||||||
|
assert.equal(output.numberOfAccounts, 0) |
||||||
|
assert.equal(output.mnemonic, null) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('#deserialize a private key', function() { |
||||||
|
it('serializes what it deserializes', function() { |
||||||
|
keyring.deserialize({ |
||||||
|
mnemonic: sampleMnemonic, |
||||||
|
numberOfAccounts: 1 |
||||||
|
}) |
||||||
|
assert.equal(keyring.wallets.length, 1, 'restores two accounts') |
||||||
|
keyring.addAccounts(1) |
||||||
|
|
||||||
|
const accounts = keyring.getAccounts() |
||||||
|
assert.equal(accounts[0], firstAcct) |
||||||
|
assert.equal(accounts[1], secondAcct) |
||||||
|
assert.equal(accounts.length, 2) |
||||||
|
|
||||||
|
const serialized = keyring.serialize() |
||||||
|
assert.equal(serialized.mnemonic, sampleMnemonic) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('#addAccounts', function() { |
||||||
|
describe('with no arguments', function() { |
||||||
|
it('creates a single wallet', function() { |
||||||
|
keyring.addAccounts() |
||||||
|
assert.equal(keyring.wallets.length, 1) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('with a numeric argument', function() { |
||||||
|
it('creates that number of wallets', function() { |
||||||
|
keyring.addAccounts(3) |
||||||
|
assert.equal(keyring.wallets.length, 3) |
||||||
|
}) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('#getAccounts', function() { |
||||||
|
it('calls getAddress on each wallet', function() { |
||||||
|
|
||||||
|
// Push a mock wallet
|
||||||
|
const desiredOutput = 'foo' |
||||||
|
keyring.wallets.push({ |
||||||
|
getAddress() { |
||||||
|
return { |
||||||
|
toString() { |
||||||
|
return desiredOutput |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const output = keyring.getAccounts() |
||||||
|
assert.equal(output[0], desiredOutput) |
||||||
|
assert.equal(output.length, 1) |
||||||
|
}) |
||||||
|
}) |
||||||
|
}) |
@ -0,0 +1,83 @@ |
|||||||
|
const assert = require('assert') |
||||||
|
const extend = require('xtend') |
||||||
|
const SimpleKeyring = require('../../../app/scripts/keyrings/simple') |
||||||
|
const TYPE_STR = 'Simple Key Pair' |
||||||
|
|
||||||
|
// Sample account:
|
||||||
|
const privKeyHex = 'b8a9c05beeedb25df85f8d641538cbffedf67216048de9c678ee26260eb91952' |
||||||
|
|
||||||
|
describe('simple-keyring', function() { |
||||||
|
|
||||||
|
let keyring |
||||||
|
beforeEach(function() { |
||||||
|
keyring = new SimpleKeyring() |
||||||
|
}) |
||||||
|
|
||||||
|
describe('Keyring.type()', function() { |
||||||
|
it('is a class method that returns the type string.', function() { |
||||||
|
const type = SimpleKeyring.type() |
||||||
|
assert.equal(type, TYPE_STR) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('#type', function() { |
||||||
|
it('returns the correct value', function() { |
||||||
|
const type = keyring.type |
||||||
|
assert.equal(type, TYPE_STR) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('#serialize empty wallets.', function() { |
||||||
|
it('serializes an empty array', function() { |
||||||
|
const output = keyring.serialize() |
||||||
|
assert.deepEqual(output, []) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('#deserialize a private key', function() { |
||||||
|
it('serializes what it deserializes', function() { |
||||||
|
keyring.deserialize([privKeyHex]) |
||||||
|
assert.equal(keyring.wallets.length, 1, 'has one wallet') |
||||||
|
|
||||||
|
const serialized = keyring.serialize() |
||||||
|
assert.equal(serialized[0], privKeyHex) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('#addAccounts', function() { |
||||||
|
describe('with no arguments', function() { |
||||||
|
it('creates a single wallet', function() { |
||||||
|
keyring.addAccounts() |
||||||
|
assert.equal(keyring.wallets.length, 1) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('with a numeric argument', function() { |
||||||
|
it('creates that number of wallets', function() { |
||||||
|
keyring.addAccounts(3) |
||||||
|
assert.equal(keyring.wallets.length, 3) |
||||||
|
}) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('#getAccounts', function() { |
||||||
|
it('calls getAddress on each wallet', function() { |
||||||
|
|
||||||
|
// Push a mock wallet
|
||||||
|
const desiredOutput = 'foo' |
||||||
|
keyring.wallets.push({ |
||||||
|
getAddress() { |
||||||
|
return { |
||||||
|
toString() { |
||||||
|
return desiredOutput |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const output = keyring.getAccounts() |
||||||
|
assert.equal(output[0], desiredOutput) |
||||||
|
assert.equal(output.length, 1) |
||||||
|
}) |
||||||
|
}) |
||||||
|
}) |
@ -1,89 +0,0 @@ |
|||||||
const connect = require('react-redux').connect |
|
||||||
const Component = require('react').Component |
|
||||||
const h = require('react-hyperscript') |
|
||||||
const inherits = require('util').inherits |
|
||||||
const actions = require('./actions') |
|
||||||
|
|
||||||
module.exports = connect(mapStateToProps)(EthStoreWarning) |
|
||||||
|
|
||||||
inherits(EthStoreWarning, Component) |
|
||||||
function EthStoreWarning () { |
|
||||||
Component.call(this) |
|
||||||
} |
|
||||||
|
|
||||||
function mapStateToProps (state) { |
|
||||||
return { |
|
||||||
selectedAccount: state.metamask.selectedAccount, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
EthStoreWarning.prototype.render = function () { |
|
||||||
|
|
||||||
return ( |
|
||||||
|
|
||||||
h('.flex-column', { |
|
||||||
key: 'ethWarning', |
|
||||||
style: { |
|
||||||
paddingTop: '25px', |
|
||||||
marginRight: '30px', |
|
||||||
marginLeft: '30px', |
|
||||||
alignItems: 'center', |
|
||||||
}, |
|
||||||
}, [ |
|
||||||
h('.warning', { |
|
||||||
style: { |
|
||||||
margin: '10px 10px 10px 10px', |
|
||||||
}, |
|
||||||
}, |
|
||||||
`MetaMask is currently in beta; use
|
|
||||||
caution in storing large |
|
||||||
amounts of ether. |
|
||||||
`),
|
|
||||||
|
|
||||||
h('i.fa.fa-exclamation-triangle.fa-4', { |
|
||||||
style: { |
|
||||||
fontSize: '152px', |
|
||||||
color: '#AEAEAE', |
|
||||||
textAlign: 'center', |
|
||||||
}, |
|
||||||
}), |
|
||||||
|
|
||||||
h('.flex-row', { |
|
||||||
style: { |
|
||||||
marginTop: '25px', |
|
||||||
marginBottom: '10px', |
|
||||||
}, |
|
||||||
}, [ |
|
||||||
h('input', { |
|
||||||
type: 'checkbox', |
|
||||||
onChange: this.toggleShowWarning.bind(this), |
|
||||||
}), |
|
||||||
h('.warning', { |
|
||||||
style: { |
|
||||||
fontSize: '11px', |
|
||||||
}, |
|
||||||
|
|
||||||
}, 'Don\'t show me this message again'), |
|
||||||
]), |
|
||||||
h('.flex-row', { |
|
||||||
style: { |
|
||||||
width: '100%', |
|
||||||
justifyContent: 'space-around', |
|
||||||
}, |
|
||||||
}, [ |
|
||||||
h('button', { |
|
||||||
onClick: this.toAccounts.bind(this), |
|
||||||
}, |
|
||||||
'Continue to MetaMask'), |
|
||||||
]), |
|
||||||
]) |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
EthStoreWarning.prototype.toggleShowWarning = function () { |
|
||||||
this.props.dispatch(actions.agreeToEthWarning()) |
|
||||||
} |
|
||||||
|
|
||||||
EthStoreWarning.prototype.toAccounts = function () { |
|
||||||
this.props.dispatch(actions.showAccountDetail(this.props.account)) |
|
||||||
} |
|
@ -1,129 +0,0 @@ |
|||||||
const inherits = require('util').inherits |
|
||||||
|
|
||||||
const Component = require('react').Component |
|
||||||
const connect = require('react-redux').connect |
|
||||||
const h = require('react-hyperscript') |
|
||||||
const actions = require('../actions') |
|
||||||
|
|
||||||
module.exports = connect(mapStateToProps)(CreateVaultScreen) |
|
||||||
|
|
||||||
inherits(CreateVaultScreen, Component) |
|
||||||
function CreateVaultScreen () { |
|
||||||
Component.call(this) |
|
||||||
} |
|
||||||
|
|
||||||
function mapStateToProps (state) { |
|
||||||
return { |
|
||||||
warning: state.appState.warning, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
CreateVaultScreen.prototype.render = function () { |
|
||||||
var state = this.props |
|
||||||
return ( |
|
||||||
|
|
||||||
h('.initialize-screen.flex-column.flex-center.flex-grow', [ |
|
||||||
|
|
||||||
h('h3.flex-center.text-transform-uppercase', { |
|
||||||
style: { |
|
||||||
background: '#EBEBEB', |
|
||||||
color: '#AEAEAE', |
|
||||||
marginBottom: 24, |
|
||||||
width: '100%', |
|
||||||
fontSize: '20px', |
|
||||||
padding: 6, |
|
||||||
}, |
|
||||||
}, [ |
|
||||||
'Create Vault', |
|
||||||
]), |
|
||||||
|
|
||||||
// password
|
|
||||||
h('input.large-input.letter-spacey', { |
|
||||||
type: 'password', |
|
||||||
id: 'password-box', |
|
||||||
placeholder: 'New Password (min 8 chars)', |
|
||||||
style: { |
|
||||||
width: 260, |
|
||||||
marginTop: 12, |
|
||||||
}, |
|
||||||
}), |
|
||||||
|
|
||||||
// confirm password
|
|
||||||
h('input.large-input.letter-spacey', { |
|
||||||
type: 'password', |
|
||||||
id: 'password-box-confirm', |
|
||||||
placeholder: 'Confirm Password', |
|
||||||
onKeyPress: this.createVaultOnEnter.bind(this), |
|
||||||
style: { |
|
||||||
width: 260, |
|
||||||
marginTop: 16, |
|
||||||
}, |
|
||||||
}), |
|
||||||
|
|
||||||
h('.flex-row.flex-space-between', { |
|
||||||
style: { |
|
||||||
marginTop: 30, |
|
||||||
width: '50%', |
|
||||||
}, |
|
||||||
}, [ |
|
||||||
|
|
||||||
// cancel
|
|
||||||
h('button.primary', { |
|
||||||
onClick: this.showInitializeMenu.bind(this), |
|
||||||
}, 'CANCEL'), |
|
||||||
|
|
||||||
// submit
|
|
||||||
h('button.primary', { |
|
||||||
onClick: this.createNewVault.bind(this), |
|
||||||
}, 'OK'), |
|
||||||
|
|
||||||
]), |
|
||||||
|
|
||||||
(!state.inProgress && state.warning) && ( |
|
||||||
h('span.in-progress-notification', state.warning) |
|
||||||
), |
|
||||||
|
|
||||||
state.inProgress && ( |
|
||||||
h('span.in-progress-notification', 'Generating Seed...') |
|
||||||
), |
|
||||||
]) |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
CreateVaultScreen.prototype.componentDidMount = function () { |
|
||||||
document.getElementById('password-box').focus() |
|
||||||
} |
|
||||||
|
|
||||||
CreateVaultScreen.prototype.showInitializeMenu = function () { |
|
||||||
this.props.dispatch(actions.showInitializeMenu()) |
|
||||||
} |
|
||||||
|
|
||||||
// create vault
|
|
||||||
|
|
||||||
CreateVaultScreen.prototype.createVaultOnEnter = function (event) { |
|
||||||
if (event.key === 'Enter') { |
|
||||||
event.preventDefault() |
|
||||||
this.createNewVault() |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
CreateVaultScreen.prototype.createNewVault = function () { |
|
||||||
var passwordBox = document.getElementById('password-box') |
|
||||||
var password = passwordBox.value |
|
||||||
var passwordConfirmBox = document.getElementById('password-box-confirm') |
|
||||||
var passwordConfirm = passwordConfirmBox.value |
|
||||||
// var entropy = document.getElementById('entropy-text-entry').value
|
|
||||||
|
|
||||||
if (password.length < 8) { |
|
||||||
this.warning = 'password not long enough' |
|
||||||
this.props.dispatch(actions.displayWarning(this.warning)) |
|
||||||
return |
|
||||||
} |
|
||||||
if (password !== passwordConfirm) { |
|
||||||
this.warning = 'passwords don\'t match' |
|
||||||
this.props.dispatch(actions.displayWarning(this.warning)) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
this.props.dispatch(actions.createNewVault(password, ''/* entropy*/)) |
|
||||||
} |
|
@ -0,0 +1,29 @@ |
|||||||
|
const inherits = require('util').inherits |
||||||
|
const Component = require('react').Component |
||||||
|
const h = require('react-hyperscript') |
||||||
|
const connect = require('react-redux').connect |
||||||
|
|
||||||
|
module.exports = connect(mapStateToProps)(NewKeychain) |
||||||
|
|
||||||
|
function mapStateToProps (state) { |
||||||
|
return {} |
||||||
|
} |
||||||
|
|
||||||
|
inherits(NewKeychain, Component) |
||||||
|
function NewKeychain () { |
||||||
|
Component.call(this) |
||||||
|
} |
||||||
|
|
||||||
|
NewKeychain.prototype.render = function () { |
||||||
|
// const props = this.props
|
||||||
|
|
||||||
|
return ( |
||||||
|
h('div', { |
||||||
|
style: { |
||||||
|
background: 'blue', |
||||||
|
}, |
||||||
|
}, [ |
||||||
|
h('h1', `Here's a list!!!!`), |
||||||
|
]) |
||||||
|
) |
||||||
|
} |
Loading…
Reference in new issue