commit
5a1d50cd43
@ -0,0 +1,70 @@ |
||||
const ObservableStore = require('obs-store') |
||||
const PendingBalanceCalculator = require('../lib/pending-balance-calculator') |
||||
const BN = require('ethereumjs-util').BN |
||||
|
||||
class BalanceController { |
||||
|
||||
constructor (opts = {}) { |
||||
const { address, accountTracker, txController, blockTracker } = opts |
||||
this.address = address |
||||
this.accountTracker = accountTracker |
||||
this.txController = txController |
||||
this.blockTracker = blockTracker |
||||
|
||||
const initState = { |
||||
ethBalance: undefined, |
||||
} |
||||
this.store = new ObservableStore(initState) |
||||
|
||||
this.balanceCalc = new PendingBalanceCalculator({ |
||||
getBalance: () => this._getBalance(), |
||||
getPendingTransactions: this._getPendingTransactions.bind(this), |
||||
}) |
||||
|
||||
this._registerUpdates() |
||||
} |
||||
|
||||
async updateBalance () { |
||||
const balance = await this.balanceCalc.getBalance() |
||||
this.store.updateState({ |
||||
ethBalance: balance, |
||||
}) |
||||
} |
||||
|
||||
_registerUpdates () { |
||||
const update = this.updateBalance.bind(this) |
||||
|
||||
this.txController.on('tx:status-update', (txId, status) => { |
||||
switch (status) { |
||||
case 'submitted': |
||||
case 'confirmed': |
||||
case 'failed': |
||||
update() |
||||
return |
||||
default: |
||||
return |
||||
} |
||||
}) |
||||
this.accountTracker.store.subscribe(update) |
||||
this.blockTracker.on('block', update) |
||||
} |
||||
|
||||
async _getBalance () { |
||||
const { accounts } = this.accountTracker.store.getState() |
||||
const entry = accounts[this.address] |
||||
const balance = entry.balance |
||||
return balance ? new BN(balance.substring(2), 16) : undefined |
||||
} |
||||
|
||||
async _getPendingTransactions () { |
||||
const pending = this.txController.getFilteredTxList({ |
||||
from: this.address, |
||||
status: 'submitted', |
||||
err: undefined, |
||||
}) |
||||
return pending |
||||
} |
||||
|
||||
} |
||||
|
||||
module.exports = BalanceController |
@ -0,0 +1,66 @@ |
||||
const ObservableStore = require('obs-store') |
||||
const extend = require('xtend') |
||||
const BalanceController = require('./balance') |
||||
|
||||
class ComputedbalancesController { |
||||
|
||||
constructor (opts = {}) { |
||||
const { accountTracker, txController, blockTracker } = opts |
||||
this.accountTracker = accountTracker |
||||
this.txController = txController |
||||
this.blockTracker = blockTracker |
||||
|
||||
const initState = extend({ |
||||
computedBalances: {}, |
||||
}, opts.initState) |
||||
this.store = new ObservableStore(initState) |
||||
this.balances = {} |
||||
|
||||
this._initBalanceUpdating() |
||||
} |
||||
|
||||
updateAllBalances () { |
||||
for (let address in this.accountTracker.store.getState().accounts) { |
||||
this.balances[address].updateBalance() |
||||
} |
||||
} |
||||
|
||||
_initBalanceUpdating () { |
||||
const store = this.accountTracker.store.getState() |
||||
this.addAnyAccountsFromStore(store) |
||||
this.accountTracker.store.subscribe(this.addAnyAccountsFromStore.bind(this)) |
||||
} |
||||
|
||||
addAnyAccountsFromStore(store) { |
||||
const balances = store.accounts |
||||
|
||||
for (let address in balances) { |
||||
this.trackAddressIfNotAlready(address) |
||||
} |
||||
} |
||||
|
||||
trackAddressIfNotAlready (address) { |
||||
const state = this.store.getState() |
||||
if (!(address in state.computedBalances)) { |
||||
this.trackAddress(address) |
||||
} |
||||
} |
||||
|
||||
trackAddress (address) { |
||||
let updater = new BalanceController({ |
||||
address, |
||||
accountTracker: this.accountTracker, |
||||
txController: this.txController, |
||||
blockTracker: this.blockTracker, |
||||
}) |
||||
updater.store.subscribe((accountBalance) => { |
||||
let newState = this.store.getState() |
||||
newState.computedBalances[address] = accountBalance |
||||
this.store.updateState(newState) |
||||
}) |
||||
this.balances[address] = updater |
||||
updater.updateBalance() |
||||
} |
||||
} |
||||
|
||||
module.exports = ComputedbalancesController |
@ -1,595 +0,0 @@ |
||||
const ethUtil = require('ethereumjs-util') |
||||
const BN = ethUtil.BN |
||||
const bip39 = require('bip39') |
||||
const EventEmitter = require('events').EventEmitter |
||||
const ObservableStore = require('obs-store') |
||||
const filter = require('promise-filter') |
||||
const encryptor = require('browser-passworder') |
||||
const sigUtil = require('eth-sig-util') |
||||
const normalizeAddress = sigUtil.normalize |
||||
// Keyrings:
|
||||
const SimpleKeyring = require('eth-simple-keyring') |
||||
const HdKeyring = require('eth-hd-keyring') |
||||
const keyringTypes = [ |
||||
SimpleKeyring, |
||||
HdKeyring, |
||||
] |
||||
|
||||
class KeyringController extends EventEmitter { |
||||
|
||||
// PUBLIC METHODS
|
||||
//
|
||||
// THE FIRST SECTION OF METHODS ARE PUBLIC-FACING,
|
||||
// MEANING THEY ARE USED BY CONSUMERS OF THIS CLASS.
|
||||
//
|
||||
// THEIR SURFACE AREA SHOULD BE CHANGED WITH GREAT CARE.
|
||||
|
||||
constructor (opts) { |
||||
super() |
||||
const initState = opts.initState || {} |
||||
this.keyringTypes = keyringTypes |
||||
this.store = new ObservableStore(initState) |
||||
this.memStore = new ObservableStore({ |
||||
isUnlocked: false, |
||||
keyringTypes: this.keyringTypes.map(krt => krt.type), |
||||
keyrings: [], |
||||
identities: {}, |
||||
}) |
||||
this.ethStore = opts.ethStore |
||||
this.encryptor = encryptor |
||||
this.keyrings = [] |
||||
this.getNetwork = opts.getNetwork |
||||
} |
||||
|
||||
// Full Update
|
||||
// returns Promise( @object state )
|
||||
//
|
||||
// Emits the `update` event and
|
||||
// returns a Promise that resolves to the current state.
|
||||
//
|
||||
// Frequently used to end asynchronous chains in this class,
|
||||
// indicating consumers can often either listen for updates,
|
||||
// or accept a state-resolving promise to consume their results.
|
||||
//
|
||||
// Not all methods end with this, that might be a nice refactor.
|
||||
fullUpdate () { |
||||
this.emit('update') |
||||
return Promise.resolve(this.memStore.getState()) |
||||
} |
||||
|
||||
// Create New Vault And Keychain
|
||||
// @string password - The password to encrypt the vault with
|
||||
//
|
||||
// returns Promise( @object state )
|
||||
//
|
||||
// Destroys any old encrypted storage,
|
||||
// creates a new encrypted store with the given password,
|
||||
// randomly creates a new HD wallet with 1 account,
|
||||
// faucets that account on the testnet.
|
||||
createNewVaultAndKeychain (password) { |
||||
return this.persistAllKeyrings(password) |
||||
.then(this.createFirstKeyTree.bind(this)) |
||||
.then(this.fullUpdate.bind(this)) |
||||
} |
||||
|
||||
// CreateNewVaultAndRestore
|
||||
// @string password - The password to encrypt the vault with
|
||||
// @string seed - The BIP44-compliant seed phrase.
|
||||
//
|
||||
// returns Promise( @object state )
|
||||
//
|
||||
// Destroys any old encrypted storage,
|
||||
// creates a new encrypted store with the given password,
|
||||
// creates a new HD wallet from the given seed with 1 account.
|
||||
createNewVaultAndRestore (password, seed) { |
||||
if (typeof password !== 'string') { |
||||
return Promise.reject('Password must be text.') |
||||
} |
||||
|
||||
if (!bip39.validateMnemonic(seed)) { |
||||
return Promise.reject(new Error('Seed phrase is invalid.')) |
||||
} |
||||
|
||||
this.clearKeyrings() |
||||
|
||||
return this.persistAllKeyrings(password) |
||||
.then(() => { |
||||
return this.addNewKeyring('HD Key Tree', { |
||||
mnemonic: seed, |
||||
numberOfAccounts: 1, |
||||
}) |
||||
}) |
||||
.then((firstKeyring) => { |
||||
return firstKeyring.getAccounts() |
||||
}) |
||||
.then((accounts) => { |
||||
const firstAccount = accounts[0] |
||||
if (!firstAccount) throw new Error('KeyringController - First Account not found.') |
||||
const hexAccount = normalizeAddress(firstAccount) |
||||
this.emit('newAccount', hexAccount) |
||||
return this.setupAccounts(accounts) |
||||
}) |
||||
.then(this.persistAllKeyrings.bind(this, password)) |
||||
.then(this.fullUpdate.bind(this)) |
||||
} |
||||
|
||||
// Set Locked
|
||||
// returns Promise( @object state )
|
||||
//
|
||||
// This method deallocates all secrets, and effectively locks metamask.
|
||||
setLocked () { |
||||
// set locked
|
||||
this.password = null |
||||
this.memStore.updateState({ isUnlocked: false }) |
||||
// remove keyrings
|
||||
this.keyrings = [] |
||||
this._updateMemStoreKeyrings() |
||||
return this.fullUpdate() |
||||
} |
||||
|
||||
// Submit Password
|
||||
// @string password
|
||||
//
|
||||
// returns Promise( @object state )
|
||||
//
|
||||
// Attempts to decrypt the current vault and load its keyrings
|
||||
// into memory.
|
||||
//
|
||||
// Temporarily also migrates any old-style vaults first, as well.
|
||||
// (Pre MetaMask 3.0.0)
|
||||
submitPassword (password) { |
||||
return this.unlockKeyrings(password) |
||||
.then((keyrings) => { |
||||
this.keyrings = keyrings |
||||
return this.fullUpdate() |
||||
}) |
||||
} |
||||
|
||||
// Add New Keyring
|
||||
// @string type
|
||||
// @object opts
|
||||
//
|
||||
// returns Promise( @Keyring keyring )
|
||||
//
|
||||
// Adds a new Keyring of the given `type` to the vault
|
||||
// and the current decrypted Keyrings array.
|
||||
//
|
||||
// All Keyring classes implement a unique `type` string,
|
||||
// and this is used to retrieve them from the keyringTypes array.
|
||||
addNewKeyring (type, opts) { |
||||
const Keyring = this.getKeyringClassForType(type) |
||||
const keyring = new Keyring(opts) |
||||
return keyring.deserialize(opts) |
||||
.then(() => { |
||||
return keyring.getAccounts() |
||||
}) |
||||
.then((accounts) => { |
||||
return this.checkForDuplicate(type, accounts) |
||||
}) |
||||
.then((checkedAccounts) => { |
||||
this.keyrings.push(keyring) |
||||
return this.setupAccounts(checkedAccounts) |
||||
}) |
||||
.then(() => this.persistAllKeyrings()) |
||||
.then(() => this._updateMemStoreKeyrings()) |
||||
.then(() => this.fullUpdate()) |
||||
.then(() => { |
||||
return keyring |
||||
}) |
||||
} |
||||
|
||||
// For now just checks for simple key pairs
|
||||
// but in the future
|
||||
// should possibly add HD and other types
|
||||
//
|
||||
checkForDuplicate (type, newAccount) { |
||||
return this.getAccounts() |
||||
.then((accounts) => { |
||||
switch (type) { |
||||
case 'Simple Key Pair': |
||||
const isNotIncluded = !accounts.find((key) => key === newAccount[0] || key === ethUtil.stripHexPrefix(newAccount[0])) |
||||
return (isNotIncluded) ? Promise.resolve(newAccount) : Promise.reject(new Error('The account you\'re are trying to import is a duplicate')) |
||||
default: |
||||
return Promise.resolve(newAccount) |
||||
} |
||||
}) |
||||
} |
||||
|
||||
|
||||
// Add New Account
|
||||
// @number keyRingNum
|
||||
//
|
||||
// returns Promise( @object state )
|
||||
//
|
||||
// Calls the `addAccounts` method on the Keyring
|
||||
// in the kryings array at index `keyringNum`,
|
||||
// and then saves those changes.
|
||||
addNewAccount (selectedKeyring) { |
||||
return selectedKeyring.addAccounts(1) |
||||
.then(this.setupAccounts.bind(this)) |
||||
.then(this.persistAllKeyrings.bind(this)) |
||||
.then(this._updateMemStoreKeyrings.bind(this)) |
||||
.then(this.fullUpdate.bind(this)) |
||||
} |
||||
|
||||
// Save Account Label
|
||||
// @string account
|
||||
// @string label
|
||||
//
|
||||
// returns Promise( @string label )
|
||||
//
|
||||
// Persists a nickname equal to `label` for the specified account.
|
||||
saveAccountLabel (account, label) { |
||||
try { |
||||
const hexAddress = normalizeAddress(account) |
||||
// update state on diskStore
|
||||
const state = this.store.getState() |
||||
const walletNicknames = state.walletNicknames || {} |
||||
walletNicknames[hexAddress] = label |
||||
this.store.updateState({ walletNicknames }) |
||||
// update state on memStore
|
||||
const identities = this.memStore.getState().identities |
||||
identities[hexAddress].name = label |
||||
this.memStore.updateState({ identities }) |
||||
return Promise.resolve(label) |
||||
} catch (err) { |
||||
return Promise.reject(err) |
||||
} |
||||
} |
||||
|
||||
// Export Account
|
||||
// @string address
|
||||
//
|
||||
// returns Promise( @string privateKey )
|
||||
//
|
||||
// Requests the private key from the keyring controlling
|
||||
// the specified address.
|
||||
//
|
||||
// Returns a Promise that may resolve with the private key string.
|
||||
exportAccount (address) { |
||||
try { |
||||
return this.getKeyringForAccount(address) |
||||
.then((keyring) => { |
||||
return keyring.exportAccount(normalizeAddress(address)) |
||||
}) |
||||
} catch (e) { |
||||
return Promise.reject(e) |
||||
} |
||||
} |
||||
|
||||
|
||||
// SIGNING METHODS
|
||||
//
|
||||
// This method signs tx and returns a promise for
|
||||
// TX Manager to update the state after signing
|
||||
|
||||
signTransaction (ethTx, _fromAddress) { |
||||
const fromAddress = normalizeAddress(_fromAddress) |
||||
return this.getKeyringForAccount(fromAddress) |
||||
.then((keyring) => { |
||||
return keyring.signTransaction(fromAddress, ethTx) |
||||
}) |
||||
} |
||||
|
||||
// Sign Message
|
||||
// @object msgParams
|
||||
//
|
||||
// returns Promise(@buffer rawSig)
|
||||
//
|
||||
// Attempts to sign the provided @object msgParams.
|
||||
signMessage (msgParams) { |
||||
const address = normalizeAddress(msgParams.from) |
||||
return this.getKeyringForAccount(address) |
||||
.then((keyring) => { |
||||
return keyring.signMessage(address, msgParams.data) |
||||
}) |
||||
} |
||||
|
||||
// Sign Personal Message
|
||||
// @object msgParams
|
||||
//
|
||||
// returns Promise(@buffer rawSig)
|
||||
//
|
||||
// Attempts to sign the provided @object msgParams.
|
||||
// Prefixes the hash before signing as per the new geth behavior.
|
||||
signPersonalMessage (msgParams) { |
||||
const address = normalizeAddress(msgParams.from) |
||||
return this.getKeyringForAccount(address) |
||||
.then((keyring) => { |
||||
return keyring.signPersonalMessage(address, msgParams.data) |
||||
}) |
||||
} |
||||
|
||||
// PRIVATE METHODS
|
||||
//
|
||||
// THESE METHODS ARE ONLY USED INTERNALLY TO THE KEYRING-CONTROLLER
|
||||
// AND SO MAY BE CHANGED MORE LIBERALLY THAN THE ABOVE METHODS.
|
||||
|
||||
// Create First Key Tree
|
||||
// returns @Promise
|
||||
//
|
||||
// Clears the vault,
|
||||
// creates a new one,
|
||||
// creates a random new HD Keyring with 1 account,
|
||||
// makes that account the selected account,
|
||||
// faucets that account on testnet,
|
||||
// puts the current seed words into the state tree.
|
||||
createFirstKeyTree () { |
||||
this.clearKeyrings() |
||||
return this.addNewKeyring('HD Key Tree', { numberOfAccounts: 1 }) |
||||
.then((keyring) => { |
||||
return keyring.getAccounts() |
||||
}) |
||||
.then((accounts) => { |
||||
const firstAccount = accounts[0] |
||||
if (!firstAccount) throw new Error('KeyringController - No account found on keychain.') |
||||
const hexAccount = normalizeAddress(firstAccount) |
||||
this.emit('newAccount', hexAccount) |
||||
this.emit('newVault', hexAccount) |
||||
return this.setupAccounts(accounts) |
||||
}) |
||||
.then(this.persistAllKeyrings.bind(this)) |
||||
} |
||||
|
||||
// Setup Accounts
|
||||
// @array accounts
|
||||
//
|
||||
// returns @Promise(@object account)
|
||||
//
|
||||
// Initializes the provided account array
|
||||
// Gives them numerically incremented nicknames,
|
||||
// and adds them to the ethStore for regular balance checking.
|
||||
setupAccounts (accounts) { |
||||
return this.getAccounts() |
||||
.then((loadedAccounts) => { |
||||
const arr = accounts || loadedAccounts |
||||
return Promise.all(arr.map((account) => { |
||||
return this.getBalanceAndNickname(account) |
||||
})) |
||||
}) |
||||
} |
||||
|
||||
// Get Balance And Nickname
|
||||
// @string account
|
||||
//
|
||||
// returns Promise( @string label )
|
||||
//
|
||||
// Takes an account address and an iterator representing
|
||||
// the current number of named accounts.
|
||||
getBalanceAndNickname (account) { |
||||
if (!account) { |
||||
throw new Error('Problem loading account.') |
||||
} |
||||
const address = normalizeAddress(account) |
||||
this.ethStore.addAccount(address) |
||||
return this.createNickname(address) |
||||
} |
||||
|
||||
// Create Nickname
|
||||
// @string address
|
||||
//
|
||||
// returns Promise( @string label )
|
||||
//
|
||||
// Takes an address, and assigns it an incremented nickname, persisting it.
|
||||
createNickname (address) { |
||||
const hexAddress = normalizeAddress(address) |
||||
const identities = this.memStore.getState().identities |
||||
const currentIdentityCount = Object.keys(identities).length + 1 |
||||
const nicknames = this.store.getState().walletNicknames || {} |
||||
const existingNickname = nicknames[hexAddress] |
||||
const name = existingNickname || `Account ${currentIdentityCount}` |
||||
identities[hexAddress] = { |
||||
address: hexAddress, |
||||
name, |
||||
} |
||||
this.memStore.updateState({ identities }) |
||||
return this.saveAccountLabel(hexAddress, name) |
||||
} |
||||
|
||||
// Persist All Keyrings
|
||||
// @password string
|
||||
//
|
||||
// returns Promise
|
||||
//
|
||||
// Iterates the current `keyrings` array,
|
||||
// serializes each one into a serialized array,
|
||||
// encrypts that array with the provided `password`,
|
||||
// and persists that encrypted string to storage.
|
||||
persistAllKeyrings (password = this.password) { |
||||
if (typeof password === 'string') { |
||||
this.password = password |
||||
this.memStore.updateState({ isUnlocked: true }) |
||||
} |
||||
return Promise.all(this.keyrings.map((keyring) => { |
||||
return Promise.all([keyring.type, keyring.serialize()]) |
||||
.then((serializedKeyringArray) => { |
||||
// Label the output values on each serialized Keyring:
|
||||
return { |
||||
type: serializedKeyringArray[0], |
||||
data: serializedKeyringArray[1], |
||||
} |
||||
}) |
||||
})) |
||||
.then((serializedKeyrings) => { |
||||
return this.encryptor.encrypt(this.password, serializedKeyrings) |
||||
}) |
||||
.then((encryptedString) => { |
||||
this.store.updateState({ vault: encryptedString }) |
||||
return true |
||||
}) |
||||
} |
||||
|
||||
// Unlock Keyrings
|
||||
// @string password
|
||||
//
|
||||
// returns Promise( @array keyrings )
|
||||
//
|
||||
// Attempts to unlock the persisted encrypted storage,
|
||||
// initializing the persisted keyrings to RAM.
|
||||
unlockKeyrings (password) { |
||||
const encryptedVault = this.store.getState().vault |
||||
if (!encryptedVault) { |
||||
throw new Error('Cannot unlock without a previous vault.') |
||||
} |
||||
|
||||
return this.encryptor.decrypt(password, encryptedVault) |
||||
.then((vault) => { |
||||
this.password = password |
||||
this.memStore.updateState({ isUnlocked: true }) |
||||
vault.forEach(this.restoreKeyring.bind(this)) |
||||
return this.keyrings |
||||
}) |
||||
} |
||||
|
||||
// Restore Keyring
|
||||
// @object serialized
|
||||
//
|
||||
// returns Promise( @Keyring deserialized )
|
||||
//
|
||||
// Attempts to initialize a new keyring from the provided
|
||||
// serialized payload.
|
||||
//
|
||||
// On success, returns the resulting @Keyring instance.
|
||||
restoreKeyring (serialized) { |
||||
const { type, data } = serialized |
||||
|
||||
const Keyring = this.getKeyringClassForType(type) |
||||
const keyring = new Keyring() |
||||
return keyring.deserialize(data) |
||||
.then(() => { |
||||
return keyring.getAccounts() |
||||
}) |
||||
.then((accounts) => { |
||||
return this.setupAccounts(accounts) |
||||
}) |
||||
.then(() => { |
||||
this.keyrings.push(keyring) |
||||
this._updateMemStoreKeyrings() |
||||
return keyring |
||||
}) |
||||
} |
||||
|
||||
// Get Keyring Class For Type
|
||||
// @string type
|
||||
//
|
||||
// Returns @class Keyring
|
||||
//
|
||||
// Searches the current `keyringTypes` array
|
||||
// for a Keyring class whose unique `type` property
|
||||
// matches the provided `type`,
|
||||
// returning it if it exists.
|
||||
getKeyringClassForType (type) { |
||||
return this.keyringTypes.find(kr => kr.type === type) |
||||
} |
||||
|
||||
getKeyringsByType (type) { |
||||
return this.keyrings.filter((keyring) => keyring.type === type) |
||||
} |
||||
|
||||
// Get Accounts
|
||||
// returns Promise( @Array[ @string accounts ] )
|
||||
//
|
||||
// Returns the public addresses of all current accounts
|
||||
// managed by all currently unlocked keyrings.
|
||||
getAccounts () { |
||||
const keyrings = this.keyrings || [] |
||||
return Promise.all(keyrings.map(kr => kr.getAccounts())) |
||||
.then((keyringArrays) => { |
||||
return keyringArrays.reduce((res, arr) => { |
||||
return res.concat(arr) |
||||
}, []) |
||||
}) |
||||
} |
||||
|
||||
// Get Keyring For Account
|
||||
// @string address
|
||||
//
|
||||
// returns Promise(@Keyring keyring)
|
||||
//
|
||||
// Returns the currently initialized keyring that manages
|
||||
// the specified `address` if one exists.
|
||||
getKeyringForAccount (address) { |
||||
const hexed = normalizeAddress(address) |
||||
log.debug(`KeyringController - getKeyringForAccount: ${hexed}`) |
||||
|
||||
return Promise.all(this.keyrings.map((keyring) => { |
||||
return Promise.all([ |
||||
keyring, |
||||
keyring.getAccounts(), |
||||
]) |
||||
})) |
||||
.then(filter((candidate) => { |
||||
const accounts = candidate[1].map(normalizeAddress) |
||||
return accounts.includes(hexed) |
||||
})) |
||||
.then((winners) => { |
||||
if (winners && winners.length > 0) { |
||||
return winners[0][0] |
||||
} else { |
||||
throw new Error('No keyring found for the requested account.') |
||||
} |
||||
}) |
||||
} |
||||
|
||||
// Display For Keyring
|
||||
// @Keyring keyring
|
||||
//
|
||||
// returns Promise( @Object { type:String, accounts:Array } )
|
||||
//
|
||||
// Is used for adding the current keyrings to the state object.
|
||||
displayForKeyring (keyring) { |
||||
return keyring.getAccounts() |
||||
.then((accounts) => { |
||||
return { |
||||
type: keyring.type, |
||||
accounts: accounts, |
||||
} |
||||
}) |
||||
} |
||||
|
||||
// Add Gas Buffer
|
||||
// @string gas (as hexadecimal value)
|
||||
//
|
||||
// returns @string bufferedGas (as hexadecimal value)
|
||||
//
|
||||
// Adds a healthy buffer of gas to an initial gas estimate.
|
||||
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)) |
||||
} |
||||
|
||||
// Clear Keyrings
|
||||
//
|
||||
// Deallocates all currently managed keyrings and accounts.
|
||||
// Used before initializing a new vault.
|
||||
clearKeyrings () { |
||||
let accounts |
||||
try { |
||||
accounts = Object.keys(this.ethStore.getState()) |
||||
} catch (e) { |
||||
accounts = [] |
||||
} |
||||
accounts.forEach((address) => { |
||||
this.ethStore.removeAccount(address) |
||||
}) |
||||
|
||||
// clear keyrings from memory
|
||||
this.keyrings = [] |
||||
this.memStore.updateState({ |
||||
keyrings: [], |
||||
identities: {}, |
||||
}) |
||||
} |
||||
|
||||
_updateMemStoreKeyrings () { |
||||
Promise.all(this.keyrings.map(this.displayForKeyring)) |
||||
.then((keyrings) => { |
||||
this.memStore.updateState({ keyrings }) |
||||
}) |
||||
} |
||||
|
||||
} |
||||
|
||||
module.exports = KeyringController |
@ -0,0 +1,104 @@ |
||||
/* Account Tracker |
||||
* |
||||
* This module is responsible for tracking any number of accounts |
||||
* and caching their current balances & transaction counts. |
||||
* |
||||
* It also tracks transaction hashes, and checks their inclusion status |
||||
* on each new block. |
||||
*/ |
||||
|
||||
const async = require('async') |
||||
const EthQuery = require('eth-query') |
||||
const ObservableStore = require('obs-store') |
||||
const EventEmitter = require('events').EventEmitter |
||||
function noop () {} |
||||
|
||||
|
||||
class AccountTracker extends EventEmitter { |
||||
|
||||
constructor (opts = {}) { |
||||
super() |
||||
|
||||
const initState = { |
||||
accounts: {}, |
||||
currentBlockGasLimit: '', |
||||
} |
||||
this.store = new ObservableStore(initState) |
||||
|
||||
this._provider = opts.provider |
||||
this._query = new EthQuery(this._provider) |
||||
this._blockTracker = opts.blockTracker |
||||
// subscribe to latest block
|
||||
this._blockTracker.on('block', this._updateForBlock.bind(this)) |
||||
// blockTracker.currentBlock may be null
|
||||
this._currentBlockNumber = this._blockTracker.currentBlock |
||||
} |
||||
|
||||
//
|
||||
// public
|
||||
//
|
||||
|
||||
addAccount (address) { |
||||
const accounts = this.store.getState().accounts |
||||
accounts[address] = {} |
||||
this.store.updateState({ accounts }) |
||||
if (!this._currentBlockNumber) return |
||||
this._updateAccount(address) |
||||
} |
||||
|
||||
removeAccount (address) { |
||||
const accounts = this.store.getState().accounts |
||||
delete accounts[address] |
||||
this.store.updateState({ accounts }) |
||||
} |
||||
|
||||
//
|
||||
// private
|
||||
//
|
||||
|
||||
_updateForBlock (block) { |
||||
this._currentBlockNumber = block.number |
||||
const currentBlockGasLimit = block.gasLimit |
||||
|
||||
this.store.updateState({ currentBlockGasLimit }) |
||||
|
||||
async.parallel([ |
||||
this._updateAccounts.bind(this), |
||||
], (err) => { |
||||
if (err) return console.error(err) |
||||
this.emit('block', this.store.getState()) |
||||
}) |
||||
} |
||||
|
||||
_updateAccounts (cb = noop) { |
||||
const accounts = this.store.getState().accounts |
||||
const addresses = Object.keys(accounts) |
||||
async.each(addresses, this._updateAccount.bind(this), cb) |
||||
} |
||||
|
||||
_updateAccount (address, cb = noop) { |
||||
this._getAccount(address, (err, result) => { |
||||
if (err) return cb(err) |
||||
result.address = address |
||||
const accounts = this.store.getState().accounts |
||||
// only populate if the entry is still present
|
||||
if (accounts[address]) { |
||||
accounts[address] = result |
||||
this.store.updateState({ accounts }) |
||||
} |
||||
cb(null, result) |
||||
}) |
||||
} |
||||
|
||||
_getAccount (address, cb = noop) { |
||||
const query = this._query |
||||
async.parallel({ |
||||
balance: query.getBalance.bind(query, address), |
||||
nonce: query.getTransactionCount.bind(query, address), |
||||
code: query.getCode.bind(query, address), |
||||
}, cb) |
||||
} |
||||
|
||||
} |
||||
|
||||
module.exports = AccountTracker |
@ -1,138 +0,0 @@ |
||||
/* Ethereum Store |
||||
* |
||||
* This module is responsible for tracking any number of accounts |
||||
* and caching their current balances & transaction counts. |
||||
* |
||||
* It also tracks transaction hashes, and checks their inclusion status |
||||
* on each new block. |
||||
*/ |
||||
|
||||
const async = require('async') |
||||
const EthQuery = require('eth-query') |
||||
const ObservableStore = require('obs-store') |
||||
function noop () {} |
||||
|
||||
|
||||
class EthereumStore extends ObservableStore { |
||||
|
||||
constructor (opts = {}) { |
||||
super({ |
||||
accounts: {}, |
||||
transactions: {}, |
||||
currentBlockNumber: '0', |
||||
currentBlockHash: '', |
||||
currentBlockGasLimit: '', |
||||
}) |
||||
this._provider = opts.provider |
||||
this._query = new EthQuery(this._provider) |
||||
this._blockTracker = opts.blockTracker |
||||
// subscribe to latest block
|
||||
this._blockTracker.on('block', this._updateForBlock.bind(this)) |
||||
// blockTracker.currentBlock may be null
|
||||
this._currentBlockNumber = this._blockTracker.currentBlock |
||||
} |
||||
|
||||
//
|
||||
// public
|
||||
//
|
||||
|
||||
addAccount (address) { |
||||
const accounts = this.getState().accounts |
||||
accounts[address] = {} |
||||
this.updateState({ accounts }) |
||||
if (!this._currentBlockNumber) return |
||||
this._updateAccount(address) |
||||
} |
||||
|
||||
removeAccount (address) { |
||||
const accounts = this.getState().accounts |
||||
delete accounts[address] |
||||
this.updateState({ accounts }) |
||||
} |
||||
|
||||
addTransaction (txHash) { |
||||
const transactions = this.getState().transactions |
||||
transactions[txHash] = {} |
||||
this.updateState({ transactions }) |
||||
if (!this._currentBlockNumber) return |
||||
this._updateTransaction(this._currentBlockNumber, txHash, noop) |
||||
} |
||||
|
||||
removeTransaction (txHash) { |
||||
const transactions = this.getState().transactions |
||||
delete transactions[txHash] |
||||
this.updateState({ transactions }) |
||||
} |
||||
|
||||
|
||||
//
|
||||
// private
|
||||
//
|
||||
|
||||
_updateForBlock (block) { |
||||
const blockNumber = '0x' + block.number.toString('hex') |
||||
this._currentBlockNumber = blockNumber |
||||
this.updateState({ currentBlockNumber: parseInt(blockNumber) }) |
||||
this.updateState({ currentBlockHash: `0x${block.hash.toString('hex')}`}) |
||||
this.updateState({ currentBlockGasLimit: `0x${block.gasLimit.toString('hex')}` }) |
||||
async.parallel([ |
||||
this._updateAccounts.bind(this), |
||||
this._updateTransactions.bind(this, blockNumber), |
||||
], (err) => { |
||||
if (err) return console.error(err) |
||||
this.emit('block', this.getState()) |
||||
}) |
||||
} |
||||
|
||||
_updateAccounts (cb = noop) { |
||||
const accounts = this.getState().accounts |
||||
const addresses = Object.keys(accounts) |
||||
async.each(addresses, this._updateAccount.bind(this), cb) |
||||
} |
||||
|
||||
_updateAccount (address, cb = noop) { |
||||
const accounts = this.getState().accounts |
||||
this._getAccount(address, (err, result) => { |
||||
if (err) return cb(err) |
||||
result.address = address |
||||
// only populate if the entry is still present
|
||||
if (accounts[address]) { |
||||
accounts[address] = result |
||||
this.updateState({ accounts }) |
||||
} |
||||
cb(null, result) |
||||
}) |
||||
} |
||||
|
||||
_updateTransactions (block, cb = noop) { |
||||
const transactions = this.getState().transactions |
||||
const txHashes = Object.keys(transactions) |
||||
async.each(txHashes, this._updateTransaction.bind(this, block), cb) |
||||
} |
||||
|
||||
_updateTransaction (block, txHash, cb = noop) { |
||||
// would use the block here to determine how many confirmations the tx has
|
||||
const transactions = this.getState().transactions |
||||
this._query.getTransaction(txHash, (err, result) => { |
||||
if (err) return cb(err) |
||||
// only populate if the entry is still present
|
||||
if (transactions[txHash]) { |
||||
transactions[txHash] = result |
||||
this.updateState({ transactions }) |
||||
} |
||||
cb(null, result) |
||||
}) |
||||
} |
||||
|
||||
_getAccount (address, cb = noop) { |
||||
const query = this._query |
||||
async.parallel({ |
||||
balance: query.getBalance.bind(query, address), |
||||
nonce: query.getTransactionCount.bind(query, address), |
||||
code: query.getCode.bind(query, address), |
||||
}, cb) |
||||
} |
||||
|
||||
} |
||||
|
||||
module.exports = EthereumStore |
@ -0,0 +1,31 @@ |
||||
module.exports = function createEventEmitterProxy(eventEmitter, listeners) { |
||||
let target = eventEmitter |
||||
const eventHandlers = listeners || {} |
||||
const proxy = new Proxy({}, { |
||||
get: (obj, name) => { |
||||
// intercept listeners
|
||||
if (name === 'on') return addListener |
||||
if (name === 'setTarget') return setTarget |
||||
if (name === 'proxyEventHandlers') return eventHandlers |
||||
return target[name] |
||||
}, |
||||
set: (obj, name, value) => { |
||||
target[name] = value |
||||
return true |
||||
}, |
||||
}) |
||||
function setTarget (eventEmitter) { |
||||
target = eventEmitter |
||||
// migrate listeners
|
||||
Object.keys(eventHandlers).forEach((name) => { |
||||
eventHandlers[name].forEach((handler) => target.on(name, handler)) |
||||
}) |
||||
} |
||||
function addListener (name, handler) { |
||||
if (!eventHandlers[name]) eventHandlers[name] = [] |
||||
eventHandlers[name].push(handler) |
||||
target.on(name, handler) |
||||
} |
||||
if (listeners) proxy.setTarget(eventEmitter) |
||||
return proxy |
||||
} |
@ -0,0 +1,51 @@ |
||||
const BN = require('ethereumjs-util').BN |
||||
const normalize = require('eth-sig-util').normalize |
||||
|
||||
class PendingBalanceCalculator { |
||||
|
||||
// Must be initialized with two functions:
|
||||
// getBalance => Returns a promise of a BN of the current balance in Wei
|
||||
// getPendingTransactions => Returns an array of TxMeta Objects,
|
||||
// which have txParams properties, which include value, gasPrice, and gas,
|
||||
// all in a base=16 hex format.
|
||||
constructor ({ getBalance, getPendingTransactions }) { |
||||
this.getPendingTransactions = getPendingTransactions |
||||
this.getNetworkBalance = getBalance |
||||
} |
||||
|
||||
async getBalance() { |
||||
const results = await Promise.all([ |
||||
this.getNetworkBalance(), |
||||
this.getPendingTransactions(), |
||||
]) |
||||
|
||||
const [ balance, pending ] = results |
||||
if (!balance) return undefined |
||||
|
||||
const pendingValue = pending.reduce((total, tx) => { |
||||
return total.add(this.calculateMaxCost(tx)) |
||||
}, new BN(0)) |
||||
|
||||
return `0x${balance.sub(pendingValue).toString(16)}` |
||||
} |
||||
|
||||
calculateMaxCost (tx) { |
||||
const txValue = tx.txParams.value |
||||
const value = this.hexToBn(txValue) |
||||
const gasPrice = this.hexToBn(tx.txParams.gasPrice) |
||||
|
||||
const gas = tx.txParams.gas |
||||
const gasLimit = tx.txParams.gasLimit |
||||
const gasLimitBn = this.hexToBn(gas || gasLimit) |
||||
|
||||
const gasCost = gasPrice.mul(gasLimitBn) |
||||
return value.add(gasCost) |
||||
} |
||||
|
||||
hexToBn (hex) { |
||||
return new BN(normalize(hex).substring(2), 16) |
||||
} |
||||
|
||||
} |
||||
|
||||
module.exports = PendingBalanceCalculator |
@ -0,0 +1,245 @@ |
||||
const extend = require('xtend') |
||||
const EventEmitter = require('events') |
||||
const ObservableStore = require('obs-store') |
||||
const ethUtil = require('ethereumjs-util') |
||||
const txStateHistoryHelper = require('./tx-state-history-helper') |
||||
|
||||
module.exports = class TransactionStateManger extends EventEmitter { |
||||
constructor ({ initState, txHistoryLimit, getNetwork }) { |
||||
super() |
||||
|
||||
this.store = new ObservableStore( |
||||
extend({ |
||||
transactions: [], |
||||
}, initState)) |
||||
this.txHistoryLimit = txHistoryLimit |
||||
this.getNetwork = getNetwork |
||||
} |
||||
|
||||
// Returns the number of txs for the current network.
|
||||
getTxCount () { |
||||
return this.getTxList().length |
||||
} |
||||
|
||||
getTxList () { |
||||
const network = this.getNetwork() |
||||
const fullTxList = this.getFullTxList() |
||||
return fullTxList.filter((txMeta) => txMeta.metamaskNetworkId === network) |
||||
} |
||||
|
||||
getFullTxList () { |
||||
return this.store.getState().transactions |
||||
} |
||||
|
||||
// Returns the tx list
|
||||
getUnapprovedTxList () { |
||||
const txList = this.getTxsByMetaData('status', 'unapproved') |
||||
return txList.reduce((result, tx) => { |
||||
result[tx.id] = tx |
||||
return result |
||||
}, {}) |
||||
} |
||||
|
||||
getPendingTransactions (address) { |
||||
const opts = { status: 'submitted' } |
||||
if (address) opts.from = address |
||||
return this.getFilteredTxList(opts) |
||||
} |
||||
|
||||
addTx (txMeta) { |
||||
this.once(`${txMeta.id}:signed`, function (txId) { |
||||
this.removeAllListeners(`${txMeta.id}:rejected`) |
||||
}) |
||||
this.once(`${txMeta.id}:rejected`, function (txId) { |
||||
this.removeAllListeners(`${txMeta.id}:signed`) |
||||
}) |
||||
// initialize history
|
||||
txMeta.history = [] |
||||
// capture initial snapshot of txMeta for history
|
||||
const snapshot = txStateHistoryHelper.snapshotFromTxMeta(txMeta) |
||||
txMeta.history.push(snapshot) |
||||
|
||||
const transactions = this.getFullTxList() |
||||
const txCount = this.getTxCount() |
||||
const txHistoryLimit = this.txHistoryLimit |
||||
|
||||
// checks if the length of the tx history is
|
||||
// longer then desired persistence limit
|
||||
// and then if it is removes only confirmed
|
||||
// or rejected tx's.
|
||||
// not tx's that are pending or unapproved
|
||||
if (txCount > txHistoryLimit - 1) { |
||||
const index = transactions.findIndex((metaTx) => metaTx.status === 'confirmed' || metaTx.status === 'rejected') |
||||
transactions.splice(index, 1) |
||||
} |
||||
transactions.push(txMeta) |
||||
this._saveTxList(transactions) |
||||
return txMeta |
||||
} |
||||
// gets tx by Id and returns it
|
||||
getTx (txId) { |
||||
const txMeta = this.getTxsByMetaData('id', txId)[0] |
||||
return txMeta |
||||
} |
||||
|
||||
updateTx (txMeta) { |
||||
if (txMeta.txParams) { |
||||
Object.keys(txMeta.txParams).forEach((key) => { |
||||
let value = txMeta.txParams[key] |
||||
if (typeof value !== 'string') console.error(`${key}: ${value} in txParams is not a string`) |
||||
if (!ethUtil.isHexPrefixed(value)) console.error('is not hex prefixed, anything on txParams must be hex prefixed') |
||||
}) |
||||
} |
||||
|
||||
// create txMeta snapshot for history
|
||||
const currentState = txStateHistoryHelper.snapshotFromTxMeta(txMeta) |
||||
// recover previous tx state obj
|
||||
const previousState = txStateHistoryHelper.replayHistory(txMeta.history) |
||||
// generate history entry and add to history
|
||||
const entry = txStateHistoryHelper.generateHistoryEntry(previousState, currentState) |
||||
txMeta.history.push(entry) |
||||
|
||||
// commit txMeta to state
|
||||
const txId = txMeta.id |
||||
const txList = this.getFullTxList() |
||||
const index = txList.findIndex(txData => txData.id === txId) |
||||
txList[index] = txMeta |
||||
this._saveTxList(txList) |
||||
} |
||||
|
||||
|
||||
// merges txParams obj onto txData.txParams
|
||||
// use extend to ensure that all fields are filled
|
||||
updateTxParams (txId, txParams) { |
||||
const txMeta = this.getTx(txId) |
||||
txMeta.txParams = extend(txMeta.txParams, txParams) |
||||
this.updateTx(txMeta) |
||||
} |
||||
|
||||
/* |
||||
Takes an object of fields to search for eg: |
||||
let thingsToLookFor = { |
||||
to: '0x0..', |
||||
from: '0x0..', |
||||
status: 'signed', |
||||
err: undefined, |
||||
} |
||||
and returns a list of tx with all |
||||
options matching |
||||
|
||||
****************HINT**************** |
||||
| `err: undefined` is like looking | |
||||
| for a tx with no err | |
||||
| so you can also search txs that | |
||||
| dont have something as well by | |
||||
| setting the value as undefined | |
||||
************************************ |
||||
|
||||
this is for things like filtering a the tx list |
||||
for only tx's from 1 account |
||||
or for filltering for all txs from one account |
||||
and that have been 'confirmed' |
||||
*/ |
||||
getFilteredTxList (opts, initialList) { |
||||
let filteredTxList = initialList |
||||
Object.keys(opts).forEach((key) => { |
||||
filteredTxList = this.getTxsByMetaData(key, opts[key], filteredTxList) |
||||
}) |
||||
return filteredTxList |
||||
} |
||||
|
||||
getTxsByMetaData (key, value, txList = this.getTxList()) { |
||||
return txList.filter((txMeta) => { |
||||
if (txMeta.txParams[key]) { |
||||
return txMeta.txParams[key] === value |
||||
} else { |
||||
return txMeta[key] === value |
||||
} |
||||
}) |
||||
} |
||||
|
||||
// STATUS METHODS
|
||||
// statuses:
|
||||
// - `'unapproved'` the user has not responded
|
||||
// - `'rejected'` the user has responded no!
|
||||
// - `'approved'` the user has approved the tx
|
||||
// - `'signed'` the tx is signed
|
||||
// - `'submitted'` the tx is sent to a server
|
||||
// - `'confirmed'` the tx has been included in a block.
|
||||
// - `'failed'` the tx failed for some reason, included on tx data.
|
||||
|
||||
// get::set status
|
||||
|
||||
// should return the status of the tx.
|
||||
getTxStatus (txId) { |
||||
const txMeta = this.getTx(txId) |
||||
return txMeta.status |
||||
} |
||||
|
||||
// should update the status of the tx to 'rejected'.
|
||||
setTxStatusRejected (txId) { |
||||
this._setTxStatus(txId, 'rejected') |
||||
} |
||||
|
||||
// should update the status of the tx to 'approved'.
|
||||
setTxStatusApproved (txId) { |
||||
this._setTxStatus(txId, 'approved') |
||||
} |
||||
|
||||
// should update the status of the tx to 'signed'.
|
||||
setTxStatusSigned (txId) { |
||||
this._setTxStatus(txId, 'signed') |
||||
} |
||||
|
||||
// should update the status of the tx to 'submitted'.
|
||||
setTxStatusSubmitted (txId) { |
||||
this._setTxStatus(txId, 'submitted') |
||||
} |
||||
|
||||
// should update the status of the tx to 'confirmed'.
|
||||
setTxStatusConfirmed (txId) { |
||||
this._setTxStatus(txId, 'confirmed') |
||||
} |
||||
|
||||
setTxStatusFailed (txId, err) { |
||||
const txMeta = this.getTx(txId) |
||||
txMeta.err = { |
||||
message: err.toString(), |
||||
stack: err.stack, |
||||
} |
||||
this.updateTx(txMeta) |
||||
this._setTxStatus(txId, 'failed') |
||||
} |
||||
|
||||
//
|
||||
// PRIVATE METHODS
|
||||
//
|
||||
|
||||
// Should find the tx in the tx list and
|
||||
// update it.
|
||||
// should set the status in txData
|
||||
// - `'unapproved'` the user has not responded
|
||||
// - `'rejected'` the user has responded no!
|
||||
// - `'approved'` the user has approved the tx
|
||||
// - `'signed'` the tx is signed
|
||||
// - `'submitted'` the tx is sent to a server
|
||||
// - `'confirmed'` the tx has been included in a block.
|
||||
// - `'failed'` the tx failed for some reason, included on tx data.
|
||||
_setTxStatus (txId, status) { |
||||
const txMeta = this.getTx(txId) |
||||
txMeta.status = status |
||||
this.emit(`${txMeta.id}:${status}`, txId) |
||||
this.emit(`tx:status-update`, txId, status) |
||||
if (status === 'submitted' || status === 'rejected') { |
||||
this.emit(`${txMeta.id}:finished`, txMeta) |
||||
} |
||||
this.updateTx(txMeta) |
||||
this.emit('update:badge') |
||||
} |
||||
|
||||
// Saves the new/updated txList.
|
||||
// Function is intended only for internal use
|
||||
_saveTxList (transactions) { |
||||
this.store.updateState({ transactions }) |
||||
} |
||||
} |
@ -0,0 +1,92 @@ |
||||
# Guide to Porting MetaMask to a New Environment |
||||
|
||||
MetaMask has been under continuous development for nearly two years now, and we’ve gradually discovered some very useful abstractions, that have allowed us to grow more easily. A couple of those layers together allow MetaMask to be ported to new environments and contexts increasingly easily. |
||||
|
||||
### The MetaMask Controller |
||||
|
||||
The core functionality of MetaMask all lives in what we call [The MetaMask Controller](https://github.com/MetaMask/metamask-extension/blob/master/app/scripts/metamask-controller.js). Our goal for this file is for it to eventually be its own javascript module that can be imported into any JS-compatible context, allowing it to fully manage an app's relationship to Ethereum. |
||||
|
||||
#### Constructor |
||||
|
||||
When calling `new MetaMask(opts)`, many platform-specific options are configured. The keys on `opts` are as follows: |
||||
|
||||
- initState: The last emitted state, used for restoring persistent state between sessions. |
||||
- platform: The `platform` object defines a variety of platform-specific functions, including opening the confirmation view, and opening web sites. |
||||
- encryptor - An object that provides access to the desired encryption methods. |
||||
|
||||
##### Encryptor |
||||
|
||||
An object that provides two simple methods, which can encrypt in any format you prefer. This parameter is optional, and will default to the browser-native WebCrypto API. |
||||
|
||||
- encrypt(password, object) - returns a Promise of a string that is ready for storage. |
||||
- decrypt(password, encryptedString) - Accepts the encrypted output of `encrypt` and returns a Promise of a restored `object` as it was encrypted. |
||||
|
||||
|
||||
##### Platform Options |
||||
|
||||
The `platform` object has a variety of options: |
||||
|
||||
- reload (function) - Will be called when MetaMask would like to reload its own context. |
||||
- openWindow ({ url }) - Will be called when MetaMask would like to open a web page. It will be passed a single `options` object with a `url` key, with a string value. |
||||
- getVersion() - Should return the current MetaMask version, as described in the current `CHANGELOG.md` or `app/manifest.json`. |
||||
|
||||
#### [metamask.getState()](https://github.com/MetaMask/metamask-extension/blob/master/app/scripts/metamask-controller.js#L241) |
||||
|
||||
This method returns a javascript object representing the current MetaMask state. This includes things like known accounts, sent transactions, current exchange rates, and more! The controller is also an event emitter, so you can subscribe to state updates via `metamask.on('update', handleStateUpdate)`. State examples available [here](https://github.com/MetaMask/metamask-extension/tree/master/development/states) under the `metamask` key. (Warning: some are outdated) |
||||
|
||||
#### [metamask.getApi()](https://github.com/MetaMask/metamask-extension/blob/master/app/scripts/metamask-controller.js#L274-L335) |
||||
|
||||
Returns a JavaScript object filled with callback functions representing every operation our user interface ever performs. Everything from creating new accounts, changing the current network, to sending a transaction, is provided via these API methods. We export this external API on an object because it allows us to easily expose this API over a port using [dnode](https://www.npmjs.com/package/dnode), which is how our WebExtension's UI works! |
||||
|
||||
### The UI |
||||
|
||||
The MetaMask UI is essentially just a website that can be configured by passing it the API and state subscriptions from above. Anyone could make a UI that consumes these, effectively reskinning MetaMask. |
||||
|
||||
You can see this in action in our file [ui/index.js](https://github.com/MetaMask/metamask-extension/blob/master/ui/index.js). There you can see an argument being passed in named `accountManager`, which is essentially a MetaMask controller (forgive its really outdated parameter name!). With access to that object, the UI is able to initialize a whole React/Redux app that relies on this API for its account/blockchain-related/persistent states. |
||||
|
||||
## Putting it Together |
||||
|
||||
As an example, a WebExtension is always defined by a `manifest.json` file. [In ours](https://github.com/MetaMask/metamask-extension/blob/master/app/manifest.json#L31), you can see that [background.js](https://github.com/MetaMask/metamask-extension/blob/master/app/scripts/background.js) is defined as a script to run in the background, and this is the file that we use to initialize the MetaMask controller. |
||||
|
||||
In that file, there's a lot going on, so it's maybe worth focusing on our MetaMask controller constructor to start. It looks something like this: |
||||
|
||||
```javascript |
||||
const controller = new MetamaskController({ |
||||
// User confirmation callbacks: |
||||
showUnconfirmedMessage: triggerUi, |
||||
unlockAccountMessage: triggerUi, |
||||
showUnapprovedTx: triggerUi, |
||||
// initial state |
||||
initState, |
||||
// platform specific api |
||||
platform, |
||||
}) |
||||
``` |
||||
Since `background.js` is essentially the Extension setup file, we can see it doing all the things specific to the extension platform: |
||||
- Defining how to open the UI for new messages, transactions, and even requests to unlock (reveal to the site) their account. |
||||
- Provide the instance's initial state, leaving MetaMask persistence to the platform. |
||||
- Providing a `platform` object. This is becoming our catch-all adapter for platforms to define a few other platform-variant features we require, like opening a web link. (Soon we will be moving encryption out here too, since our browser-encryption isn't portable enough!) |
||||
|
||||
## Ports, streams, and Web3! |
||||
|
||||
Everything so far has been enough to create a MetaMask wallet on virtually any platform that runs JS, but MetaMask's most unique feature isn't being a wallet, it's providing an Ethereum-enabled JavaScript context to websites. |
||||
|
||||
MetaMask has two kinds of [duplex stream APIs](https://github.com/substack/stream-handbook#duplex) that it exposes: |
||||
- [metamask.setupTrustedCommunication(connectionStream, originDomain)](https://github.com/MetaMask/metamask-extension/blob/master/app/scripts/metamask-controller.js#L352) - This stream is used to connect the user interface over a remote port, and may not be necessary for contexts where the interface and the metamask-controller share a process. |
||||
- [metamask.setupUntrustedCommunication(connectionStream, originDomain)](https://github.com/MetaMask/metamask-extension/blob/master/app/scripts/metamask-controller.js#L337) - This method is used to connect a new web site's web3 API to MetaMask's blockchain connection. Additionally, the `originDomain` is used to block detected phishing sites. |
||||
|
||||
### Web3 as a Stream |
||||
|
||||
If you are making a MetaMask-powered browser for a new platform, one of the trickiest tasks will be injecting the Web3 API into websites that are visited. On WebExtensions, we actually have to pipe data through a total of three JS contexts just to let sites talk to our background process (site -> contentscript -> background). |
||||
|
||||
To make this as easy as possible, we use one of our favorite internal tools, [web3-provider-engine](https://www.npmjs.com/package/web3-provider-engine) to construct a custom web3 provider object whose source of truth is a stream that we connect to remotely. |
||||
|
||||
To see how we do that, you can refer to the [inpage script](https://github.com/MetaMask/metamask-extension/blob/master/app/scripts/inpage.js) that we inject into every website. There you can see it creates a multiplex stream to the background, and uses it to initialize what we call the [inpage-provider](https://github.com/MetaMask/metamask-extension/blob/master/app/scripts/lib/inpage-provider.js), which you can see stubs a few methods out, but mostly just passes calls to `sendAsync` through the stream it's passed! That's really all the magic that's needed to create a web3-like API in a remote context, once you have a stream to MetaMask available. |
||||
|
||||
In `inpage.js` you can see we create a `PortStream`, that's just a class we use to wrap WebExtension ports as streams, so we can reuse our favorite stream abstraction over the more irregular API surface of the WebExtension. In a new platform, you will probably need to construct this stream differently. The key is that you need to construct a stream that talks from the site context to the background. Once you have that set up, it works like magic! |
||||
|
||||
If streams seem new and confusing to you, that's ok, they can seem strange at first. To help learn them, we highly recommend reading Substack's [Stream Handbook](https://github.com/substack/stream-handbook), or going through NodeSchool's interactive command-line class [Stream Adventure](https://github.com/workshopper/stream-adventure), also maintained by Substack. |
||||
|
||||
## Conclusion |
||||
|
||||
I hope this has been helpful to you! If you have any other questionsm, or points you think need clarification in this guide, please [open an issue on our GitHub](https://github.com/MetaMask/metamask-plugin/issues/new)! |
@ -1,57 +1,26 @@ |
||||
window.addEventListener('load', web3Detect) |
||||
const EthQuery = require('ethjs-query') |
||||
|
||||
window.addEventListener('load', loadProvider) |
||||
window.addEventListener('message', console.warn) |
||||
|
||||
function web3Detect() { |
||||
if (global.web3) { |
||||
logToDom('web3 detected!') |
||||
startApp() |
||||
} else { |
||||
logToDom('no web3 detected!') |
||||
} |
||||
async function loadProvider() { |
||||
const ethereumProvider = window.metamask.createDefaultProvider({ host: 'http://localhost:9001' }) |
||||
const ethQuery = new EthQuery(ethereumProvider) |
||||
const accounts = await ethQuery.accounts() |
||||
logToDom(accounts.length ? accounts[0] : 'LOCKED or undefined') |
||||
setupButton(ethQuery) |
||||
} |
||||
|
||||
function startApp(){ |
||||
console.log('app started') |
||||
|
||||
var primaryAccount |
||||
console.log('getting main account...') |
||||
web3.eth.getAccounts((err, addresses) => { |
||||
if (err) console.error(err) |
||||
console.log('set address', addresses[0]) |
||||
primaryAccount = addresses[0] |
||||
}) |
||||
|
||||
document.querySelector('.action-button-1').addEventListener('click', function(){ |
||||
console.log('saw click') |
||||
console.log('sending tx') |
||||
primaryAccount |
||||
web3.eth.sendTransaction({ |
||||
from: primaryAccount, |
||||
to: primaryAccount, |
||||
value: 0, |
||||
}, function(err, txHash){ |
||||
if (err) throw err |
||||
console.log('sendTransaction result:', err || txHash) |
||||
}) |
||||
}) |
||||
document.querySelector('.action-button-2').addEventListener('click', function(){ |
||||
console.log('saw click') |
||||
setTimeout(function(){ |
||||
console.log('sending tx') |
||||
web3.eth.sendTransaction({ |
||||
from: primaryAccount, |
||||
to: primaryAccount, |
||||
value: 0, |
||||
}, function(err, txHash){ |
||||
if (err) throw err |
||||
console.log('sendTransaction result:', err || txHash) |
||||
}) |
||||
}) |
||||
}) |
||||
|
||||
} |
||||
|
||||
function logToDom(message){ |
||||
document.body.appendChild(document.createTextNode(message)) |
||||
document.getElementById('account').innerText = message |
||||
console.log(message) |
||||
} |
||||
|
||||
function setupButton (ethQuery) { |
||||
const button = document.getElementById('action-button-1') |
||||
button.addEventListener('click', async () => { |
||||
const accounts = await ethQuery.accounts() |
||||
logToDom(accounts.length ? accounts[0] : 'LOCKED or undefined') |
||||
}) |
||||
} |
@ -1,19 +0,0 @@ |
||||
const Iframe = require('iframe') |
||||
const createIframeStream = require('iframe-stream').IframeStream |
||||
|
||||
module.exports = setupIframe |
||||
|
||||
|
||||
function setupIframe(opts) { |
||||
opts = opts || {} |
||||
var frame = Iframe({ |
||||
src: opts.zeroClientProvider || 'https://zero.metamask.io/', |
||||
container: opts.container || document.head, |
||||
sandboxAttributes: opts.sandboxAttributes || ['allow-scripts', 'allow-popups'], |
||||
}) |
||||
var iframe = frame.iframe |
||||
iframe.style.setProperty('display', 'none') |
||||
var iframeStream = createIframeStream(iframe) |
||||
|
||||
return iframeStream |
||||
} |
@ -1,22 +0,0 @@ |
||||
const setupIframe = require('./setup-iframe.js') |
||||
const MetamaskInpageProvider = require('../../../app/scripts/lib/inpage-provider.js') |
||||
|
||||
module.exports = getProvider |
||||
|
||||
|
||||
function getProvider(opts){ |
||||
if (global.web3) { |
||||
console.log('MetaMask ZeroClient - using environmental web3 provider') |
||||
return global.web3.currentProvider |
||||
} |
||||
console.log('MetaMask ZeroClient - injecting zero-client iframe!') |
||||
var iframeStream = setupIframe({ |
||||
zeroClientProvider: opts.mascaraUrl, |
||||
sandboxAttributes: ['allow-scripts', 'allow-popups', 'allow-same-origin'], |
||||
container: document.body, |
||||
}) |
||||
|
||||
var inpageProvider = new MetamaskInpageProvider(iframeStream) |
||||
return inpageProvider |
||||
|
||||
} |
@ -1,47 +1 @@ |
||||
const Web3 = require('web3') |
||||
const setupProvider = require('./lib/setup-provider.js') |
||||
const setupDappAutoReload = require('../../app/scripts/lib/auto-reload.js') |
||||
const MASCARA_ORIGIN = process.env.MASCARA_ORIGIN || 'http://localhost:9001' |
||||
console.log('MASCARA_ORIGIN:', MASCARA_ORIGIN) |
||||
|
||||
//
|
||||
// setup web3
|
||||
//
|
||||
|
||||
const provider = setupProvider({ |
||||
mascaraUrl: MASCARA_ORIGIN + '/proxy/', |
||||
}) |
||||
instrumentForUserInteractionTriggers(provider) |
||||
|
||||
const web3 = new Web3(provider) |
||||
setupDappAutoReload(web3, provider.publicConfigStore) |
||||
//
|
||||
// ui stuff
|
||||
//
|
||||
|
||||
let shouldPop = false |
||||
window.addEventListener('click', maybeTriggerPopup) |
||||
|
||||
//
|
||||
// util
|
||||
//
|
||||
|
||||
function maybeTriggerPopup(){ |
||||
if (!shouldPop) return |
||||
shouldPop = false |
||||
window.open(MASCARA_ORIGIN, '', 'width=360 height=500') |
||||
console.log('opening window...') |
||||
} |
||||
|
||||
function instrumentForUserInteractionTriggers(provider){ |
||||
const _super = provider.sendAsync.bind(provider) |
||||
provider.sendAsync = function(payload, cb){ |
||||
if (payload.method === 'eth_sendTransaction') { |
||||
console.log('saw send') |
||||
shouldPop = true |
||||
} |
||||
_super(payload, cb) |
||||
} |
||||
} |
||||
|
||||
|
||||
global.metamask = require('metamascara') |
||||
|
@ -1,167 +0,0 @@ |
||||
const assert = require('assert') |
||||
const KeyringController = require('../../app/scripts/keyring-controller') |
||||
const configManagerGen = require('../lib/mock-config-manager') |
||||
const ethUtil = require('ethereumjs-util') |
||||
const BN = ethUtil.BN |
||||
const mockEncryptor = require('../lib/mock-encryptor') |
||||
const sinon = require('sinon') |
||||
|
||||
describe('KeyringController', function () { |
||||
let keyringController |
||||
const password = 'password123' |
||||
const seedWords = 'puzzle seed penalty soldier say clay field arctic metal hen cage runway' |
||||
const addresses = ['eF35cA8EbB9669A35c31b5F6f249A9941a812AC1'.toLowerCase()] |
||||
const accounts = [] |
||||
// let originalKeystore
|
||||
|
||||
beforeEach(function (done) { |
||||
this.sinon = sinon.sandbox.create() |
||||
window.localStorage = {} // Hacking localStorage support into JSDom
|
||||
|
||||
keyringController = new KeyringController({ |
||||
configManager: configManagerGen(), |
||||
txManager: { |
||||
getTxList: () => [], |
||||
getUnapprovedTxList: () => [], |
||||
}, |
||||
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) |
||||
.then(function (newState) { |
||||
newState |
||||
done() |
||||
}) |
||||
.catch((err) => { |
||||
done(err) |
||||
}) |
||||
}) |
||||
|
||||
afterEach(function () { |
||||
// Cleanup mocks
|
||||
this.sinon.restore() |
||||
}) |
||||
|
||||
describe('#createNewVaultAndKeychain', function () { |
||||
this.timeout(10000) |
||||
|
||||
it('should set a vault on the configManager', function (done) { |
||||
keyringController.store.updateState({ vault: null }) |
||||
assert(!keyringController.store.getState().vault, 'no previous vault') |
||||
keyringController.createNewVaultAndKeychain(password) |
||||
.then(() => { |
||||
const vault = keyringController.store.getState().vault |
||||
assert(vault, 'vault created') |
||||
done() |
||||
}) |
||||
.catch((reason) => { |
||||
done(reason) |
||||
}) |
||||
}) |
||||
}) |
||||
|
||||
describe('#restoreKeyring', function () { |
||||
it(`should pass a keyring's serialized data back to the correct type.`, function (done) { |
||||
const mockSerialized = { |
||||
type: 'HD Key Tree', |
||||
data: { |
||||
mnemonic: seedWords, |
||||
numberOfAccounts: 1, |
||||
}, |
||||
} |
||||
const mock = this.sinon.mock(keyringController) |
||||
|
||||
mock.expects('getBalanceAndNickname') |
||||
.exactly(1) |
||||
|
||||
keyringController.restoreKeyring(mockSerialized) |
||||
.then((keyring) => { |
||||
assert.equal(keyring.wallets.length, 1, 'one wallet restored') |
||||
return keyring.getAccounts() |
||||
}) |
||||
.then((accounts) => { |
||||
assert.equal(accounts[0], addresses[0]) |
||||
mock.verify() |
||||
done() |
||||
}) |
||||
.catch((reason) => { |
||||
done(reason) |
||||
}) |
||||
}) |
||||
}) |
||||
|
||||
describe('#createNickname', function () { |
||||
it('should add the address to the identities hash', function () { |
||||
const fakeAddress = '0x12345678' |
||||
keyringController.createNickname(fakeAddress) |
||||
const identities = keyringController.memStore.getState().identities |
||||
const identity = identities[fakeAddress] |
||||
assert.equal(identity.address, fakeAddress) |
||||
}) |
||||
}) |
||||
|
||||
describe('#saveAccountLabel', function () { |
||||
it('sets the nickname', function (done) { |
||||
const account = addresses[0] |
||||
var nick = 'Test nickname' |
||||
const identities = keyringController.memStore.getState().identities |
||||
identities[ethUtil.addHexPrefix(account)] = {} |
||||
keyringController.memStore.updateState({ identities }) |
||||
keyringController.saveAccountLabel(account, nick) |
||||
.then((label) => { |
||||
try { |
||||
assert.equal(label, nick) |
||||
const persisted = keyringController.store.getState().walletNicknames[account] |
||||
assert.equal(persisted, nick) |
||||
done() |
||||
} catch (err) { |
||||
done() |
||||
} |
||||
}) |
||||
.catch((reason) => { |
||||
done(reason) |
||||
}) |
||||
}) |
||||
}) |
||||
|
||||
describe('#getAccounts', function () { |
||||
it('returns the result of getAccounts for each keyring', function (done) { |
||||
keyringController.keyrings = [ |
||||
{ getAccounts () { return Promise.resolve([1, 2, 3]) } }, |
||||
{ getAccounts () { return Promise.resolve([4, 5, 6]) } }, |
||||
] |
||||
|
||||
keyringController.getAccounts() |
||||
.then((result) => { |
||||
assert.deepEqual(result, [1, 2, 3, 4, 5, 6]) |
||||
done() |
||||
}) |
||||
}) |
||||
}) |
||||
|
||||
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,93 @@ |
||||
const assert = require('assert') |
||||
const PendingBalanceCalculator = require('../../app/scripts/lib/pending-balance-calculator') |
||||
const MockTxGen = require('../lib/mock-tx-gen') |
||||
const BN = require('ethereumjs-util').BN |
||||
let providerResultStub = {} |
||||
|
||||
const zeroBn = new BN(0) |
||||
const etherBn = new BN(String(1e18)) |
||||
const ether = '0x' + etherBn.toString(16) |
||||
|
||||
describe('PendingBalanceCalculator', function () { |
||||
let balanceCalculator |
||||
|
||||
describe('#calculateMaxCost(tx)', function () { |
||||
it('returns a BN for a given tx value', function () { |
||||
const txGen = new MockTxGen() |
||||
pendingTxs = txGen.generate({ |
||||
status: 'submitted', |
||||
txParams: { |
||||
value: ether, |
||||
gasPrice: '0x0', |
||||
gas: '0x0', |
||||
} |
||||
}, { count: 1 }) |
||||
|
||||
const balanceCalculator = generateBalanceCalcWith([], zeroBn) |
||||
const result = balanceCalculator.calculateMaxCost(pendingTxs[0]) |
||||
assert.equal(result.toString(), etherBn.toString(), 'computes one ether') |
||||
}) |
||||
|
||||
it('calculates gas costs as well', function () { |
||||
const txGen = new MockTxGen() |
||||
pendingTxs = txGen.generate({ |
||||
status: 'submitted', |
||||
txParams: { |
||||
value: '0x0', |
||||
gasPrice: '0x2', |
||||
gas: '0x3', |
||||
} |
||||
}, { count: 1 }) |
||||
|
||||
const balanceCalculator = generateBalanceCalcWith([], zeroBn) |
||||
const result = balanceCalculator.calculateMaxCost(pendingTxs[0]) |
||||
assert.equal(result.toString(), '6', 'computes 6 wei of gas') |
||||
}) |
||||
}) |
||||
|
||||
describe('if you have no pending txs and one ether', function () { |
||||
|
||||
beforeEach(function () { |
||||
balanceCalculator = generateBalanceCalcWith([], etherBn) |
||||
}) |
||||
|
||||
it('returns the network balance', async function () { |
||||
const result = await balanceCalculator.getBalance() |
||||
assert.equal(result, ether, `gave ${result} needed ${ether}`) |
||||
}) |
||||
}) |
||||
|
||||
describe('if you have a one ether pending tx and one ether', function () { |
||||
beforeEach(function () { |
||||
const txGen = new MockTxGen() |
||||
pendingTxs = txGen.generate({ |
||||
status: 'submitted', |
||||
txParams: { |
||||
value: ether, |
||||
gasPrice: '0x0', |
||||
gas: '0x0', |
||||
} |
||||
}, { count: 1 }) |
||||
|
||||
balanceCalculator = generateBalanceCalcWith(pendingTxs, etherBn) |
||||
}) |
||||
|
||||
it('returns the subtracted result', async function () { |
||||
const result = await balanceCalculator.getBalance() |
||||
assert.equal(result, '0x0', `gave ${result} needed '0x0'`) |
||||
return true |
||||
}) |
||||
|
||||
}) |
||||
}) |
||||
|
||||
function generateBalanceCalcWith (transactions, providerStub = zeroBn) { |
||||
const getPendingTransactions = async () => transactions |
||||
const getBalance = async () => providerStub |
||||
|
||||
return new PendingBalanceCalculator({ |
||||
getBalance, |
||||
getPendingTransactions, |
||||
}) |
||||
} |
||||
|
@ -0,0 +1,241 @@ |
||||
const assert = require('assert') |
||||
const clone = require('clone') |
||||
const ObservableStore = require('obs-store') |
||||
const TxStateManager = require('../../app/scripts/lib/tx-state-manager') |
||||
const txStateHistoryHelper = require('../../app/scripts/lib/tx-state-history-helper') |
||||
const noop = () => true |
||||
|
||||
describe('TransactionStateManger', function () { |
||||
let txStateManager |
||||
const currentNetworkId = 42 |
||||
const otherNetworkId = 2 |
||||
|
||||
beforeEach(function () { |
||||
txStateManager = new TxStateManager({ |
||||
initState: { |
||||
transactions: [], |
||||
}, |
||||
txHistoryLimit: 10, |
||||
getNetwork: () => currentNetworkId |
||||
}) |
||||
}) |
||||
|
||||
describe('#setTxStatusSigned', function () { |
||||
it('sets the tx status to signed', function () { |
||||
let tx = { id: 1, status: 'unapproved', metamaskNetworkId: currentNetworkId, txParams: {} } |
||||
txStateManager.addTx(tx, noop) |
||||
txStateManager.setTxStatusSigned(1) |
||||
let result = txStateManager.getTxList() |
||||
assert.ok(Array.isArray(result)) |
||||
assert.equal(result.length, 1) |
||||
assert.equal(result[0].status, 'signed') |
||||
}) |
||||
|
||||
it('should emit a signed event to signal the exciton of callback', (done) => { |
||||
let tx = { id: 1, status: 'unapproved', metamaskNetworkId: currentNetworkId, txParams: {} } |
||||
const noop = function () { |
||||
assert(true, 'event listener has been triggered and noop executed') |
||||
done() |
||||
} |
||||
txStateManager.addTx(tx) |
||||
txStateManager.on('1:signed', noop) |
||||
txStateManager.setTxStatusSigned(1) |
||||
|
||||
}) |
||||
}) |
||||
|
||||
describe('#setTxStatusRejected', function () { |
||||
it('sets the tx status to rejected', function () { |
||||
let tx = { id: 1, status: 'unapproved', metamaskNetworkId: currentNetworkId, txParams: {} } |
||||
txStateManager.addTx(tx) |
||||
txStateManager.setTxStatusRejected(1) |
||||
let result = txStateManager.getTxList() |
||||
assert.ok(Array.isArray(result)) |
||||
assert.equal(result.length, 1) |
||||
assert.equal(result[0].status, 'rejected') |
||||
}) |
||||
|
||||
it('should emit a rejected event to signal the exciton of callback', (done) => { |
||||
let tx = { id: 1, status: 'unapproved', metamaskNetworkId: currentNetworkId, txParams: {} } |
||||
txStateManager.addTx(tx) |
||||
const noop = function (err, txId) { |
||||
assert(true, 'event listener has been triggered and noop executed') |
||||
done() |
||||
} |
||||
txStateManager.on('1:rejected', noop) |
||||
txStateManager.setTxStatusRejected(1) |
||||
}) |
||||
}) |
||||
|
||||
describe('#getFullTxList', function () { |
||||
it('when new should return empty array', function () { |
||||
let result = txStateManager.getTxList() |
||||
assert.ok(Array.isArray(result)) |
||||
assert.equal(result.length, 0) |
||||
}) |
||||
}) |
||||
|
||||
describe('#getTxList', function () { |
||||
it('when new should return empty array', function () { |
||||
let result = txStateManager.getTxList() |
||||
assert.ok(Array.isArray(result)) |
||||
assert.equal(result.length, 0) |
||||
}) |
||||
}) |
||||
|
||||
describe('#addTx', function () { |
||||
it('adds a tx returned in getTxList', function () { |
||||
let tx = { id: 1, status: 'confirmed', metamaskNetworkId: currentNetworkId, txParams: {} } |
||||
txStateManager.addTx(tx, noop) |
||||
let result = txStateManager.getTxList() |
||||
assert.ok(Array.isArray(result)) |
||||
assert.equal(result.length, 1) |
||||
assert.equal(result[0].id, 1) |
||||
}) |
||||
|
||||
it('does not override txs from other networks', function () { |
||||
let tx = { id: 1, status: 'confirmed', metamaskNetworkId: currentNetworkId, txParams: {} } |
||||
let tx2 = { id: 2, status: 'confirmed', metamaskNetworkId: otherNetworkId, txParams: {} } |
||||
txStateManager.addTx(tx, noop) |
||||
txStateManager.addTx(tx2, noop) |
||||
let result = txStateManager.getFullTxList() |
||||
let result2 = txStateManager.getTxList() |
||||
assert.equal(result.length, 2, 'txs were deleted') |
||||
assert.equal(result2.length, 1, 'incorrect number of txs on network.') |
||||
}) |
||||
|
||||
it('cuts off early txs beyond a limit', function () { |
||||
const limit = txStateManager.txHistoryLimit |
||||
for (let i = 0; i < limit + 1; i++) { |
||||
const tx = { id: i, time: new Date(), status: 'confirmed', metamaskNetworkId: currentNetworkId, txParams: {} } |
||||
txStateManager.addTx(tx, noop) |
||||
} |
||||
let result = txStateManager.getTxList() |
||||
assert.equal(result.length, limit, `limit of ${limit} txs enforced`) |
||||
assert.equal(result[0].id, 1, 'early txs truncted') |
||||
}) |
||||
|
||||
it('cuts off early txs beyond a limit whether or not it is confirmed or rejected', function () { |
||||
const limit = txStateManager.txHistoryLimit |
||||
for (let i = 0; i < limit + 1; i++) { |
||||
const tx = { id: i, time: new Date(), status: 'rejected', metamaskNetworkId: currentNetworkId, txParams: {} } |
||||
txStateManager.addTx(tx, noop) |
||||
} |
||||
let result = txStateManager.getTxList() |
||||
assert.equal(result.length, limit, `limit of ${limit} txs enforced`) |
||||
assert.equal(result[0].id, 1, 'early txs truncted') |
||||
}) |
||||
|
||||
it('cuts off early txs beyond a limit but does not cut unapproved txs', function () { |
||||
let unconfirmedTx = { id: 0, time: new Date(), status: 'unapproved', metamaskNetworkId: currentNetworkId, txParams: {} } |
||||
txStateManager.addTx(unconfirmedTx, noop) |
||||
const limit = txStateManager.txHistoryLimit |
||||
for (let i = 1; i < limit + 1; i++) { |
||||
const tx = { id: i, time: new Date(), status: 'confirmed', metamaskNetworkId: currentNetworkId, txParams: {} } |
||||
txStateManager.addTx(tx, noop) |
||||
} |
||||
let result = txStateManager.getTxList() |
||||
assert.equal(result.length, limit, `limit of ${limit} txs enforced`) |
||||
assert.equal(result[0].id, 0, 'first tx should still be there') |
||||
assert.equal(result[0].status, 'unapproved', 'first tx should be unapproved') |
||||
assert.equal(result[1].id, 2, 'early txs truncted') |
||||
}) |
||||
}) |
||||
|
||||
describe('#updateTx', function () { |
||||
it('replaces the tx with the same id', function () { |
||||
txStateManager.addTx({ id: '1', status: 'unapproved', metamaskNetworkId: currentNetworkId, txParams: {} }, noop) |
||||
txStateManager.addTx({ id: '2', status: 'confirmed', metamaskNetworkId: currentNetworkId, txParams: {} }, noop) |
||||
const txMeta = txStateManager.getTx('1') |
||||
txMeta.hash = 'foo' |
||||
txStateManager.updateTx(txMeta) |
||||
let result = txStateManager.getTx('1') |
||||
assert.equal(result.hash, 'foo') |
||||
}) |
||||
|
||||
it('updates gas price and adds history items', function () { |
||||
const originalGasPrice = '0x01' |
||||
const desiredGasPrice = '0x02' |
||||
|
||||
const txMeta = { |
||||
id: '1', |
||||
status: 'unapproved', |
||||
metamaskNetworkId: currentNetworkId, |
||||
txParams: { |
||||
gasPrice: originalGasPrice, |
||||
}, |
||||
} |
||||
|
||||
const updatedMeta = clone(txMeta) |
||||
|
||||
txStateManager.addTx(txMeta) |
||||
const updatedTx = txStateManager.getTx('1') |
||||
// verify tx was initialized correctly
|
||||
assert.equal(updatedTx.history.length, 1, 'one history item (initial)') |
||||
assert.equal(Array.isArray(updatedTx.history[0]), false, 'first history item is initial state') |
||||
assert.deepEqual(updatedTx.history[0], txStateHistoryHelper.snapshotFromTxMeta(updatedTx), 'first history item is initial state') |
||||
// modify value and updateTx
|
||||
updatedTx.txParams.gasPrice = desiredGasPrice |
||||
txStateManager.updateTx(updatedTx) |
||||
// check updated value
|
||||
const result = txStateManager.getTx('1') |
||||
assert.equal(result.txParams.gasPrice, desiredGasPrice, 'gas price updated') |
||||
// validate history was updated
|
||||
assert.equal(result.history.length, 2, 'two history items (initial + diff)') |
||||
const expectedEntry = { op: 'replace', path: '/txParams/gasPrice', value: desiredGasPrice } |
||||
assert.deepEqual(result.history[1], [expectedEntry], 'two history items (initial + diff)') |
||||
}) |
||||
}) |
||||
|
||||
describe('#getUnapprovedTxList', function () { |
||||
it('returns unapproved txs in a hash', function () { |
||||
txStateManager.addTx({ id: '1', status: 'unapproved', metamaskNetworkId: currentNetworkId, txParams: {} }, noop) |
||||
txStateManager.addTx({ id: '2', status: 'confirmed', metamaskNetworkId: currentNetworkId, txParams: {} }, noop) |
||||
const result = txStateManager.getUnapprovedTxList() |
||||
assert.equal(typeof result, 'object') |
||||
assert.equal(result['1'].status, 'unapproved') |
||||
assert.equal(result['2'], undefined) |
||||
}) |
||||
}) |
||||
|
||||
describe('#getTx', function () { |
||||
it('returns a tx with the requested id', function () { |
||||
txStateManager.addTx({ id: '1', status: 'unapproved', metamaskNetworkId: currentNetworkId, txParams: {} }, noop) |
||||
txStateManager.addTx({ id: '2', status: 'confirmed', metamaskNetworkId: currentNetworkId, txParams: {} }, noop) |
||||
assert.equal(txStateManager.getTx('1').status, 'unapproved') |
||||
assert.equal(txStateManager.getTx('2').status, 'confirmed') |
||||
}) |
||||
}) |
||||
|
||||
describe('#getFilteredTxList', function () { |
||||
it('returns a tx with the requested data', function () { |
||||
const txMetas = [ |
||||
{ id: 0, status: 'unapproved', txParams: { from: '0xaa', to: '0xbb' }, metamaskNetworkId: currentNetworkId }, |
||||
{ id: 1, status: 'unapproved', txParams: { from: '0xaa', to: '0xbb' }, metamaskNetworkId: currentNetworkId }, |
||||
{ id: 2, status: 'unapproved', txParams: { from: '0xaa', to: '0xbb' }, metamaskNetworkId: currentNetworkId }, |
||||
{ id: 3, status: 'unapproved', txParams: { from: '0xbb', to: '0xaa' }, metamaskNetworkId: currentNetworkId }, |
||||
{ id: 4, status: 'unapproved', txParams: { from: '0xbb', to: '0xaa' }, metamaskNetworkId: currentNetworkId }, |
||||
{ id: 5, status: 'confirmed', txParams: { from: '0xaa', to: '0xbb' }, metamaskNetworkId: currentNetworkId }, |
||||
{ id: 6, status: 'confirmed', txParams: { from: '0xaa', to: '0xbb' }, metamaskNetworkId: currentNetworkId }, |
||||
{ id: 7, status: 'confirmed', txParams: { from: '0xbb', to: '0xaa' }, metamaskNetworkId: currentNetworkId }, |
||||
{ id: 8, status: 'confirmed', txParams: { from: '0xbb', to: '0xaa' }, metamaskNetworkId: currentNetworkId }, |
||||
{ id: 9, status: 'confirmed', txParams: { from: '0xbb', to: '0xaa' }, metamaskNetworkId: currentNetworkId }, |
||||
] |
||||
txMetas.forEach((txMeta) => txStateManager.addTx(txMeta, noop)) |
||||
let filterParams |
||||
|
||||
filterParams = { status: 'unapproved', from: '0xaa' } |
||||
assert.equal(txStateManager.getFilteredTxList(filterParams).length, 3, `getFilteredTxList - ${JSON.stringify(filterParams)}`) |
||||
filterParams = { status: 'unapproved', to: '0xaa' } |
||||
assert.equal(txStateManager.getFilteredTxList(filterParams).length, 2, `getFilteredTxList - ${JSON.stringify(filterParams)}`) |
||||
filterParams = { status: 'confirmed', from: '0xbb' } |
||||
assert.equal(txStateManager.getFilteredTxList(filterParams).length, 3, `getFilteredTxList - ${JSON.stringify(filterParams)}`) |
||||
filterParams = { status: 'confirmed' } |
||||
assert.equal(txStateManager.getFilteredTxList(filterParams).length, 5, `getFilteredTxList - ${JSON.stringify(filterParams)}`) |
||||
filterParams = { from: '0xaa' } |
||||
assert.equal(txStateManager.getFilteredTxList(filterParams).length, 5, `getFilteredTxList - ${JSON.stringify(filterParams)}`) |
||||
filterParams = { to: '0xaa' } |
||||
assert.equal(txStateManager.getFilteredTxList(filterParams).length, 5, `getFilteredTxList - ${JSON.stringify(filterParams)}`) |
||||
}) |
||||
}) |
||||
}) |
@ -1,207 +0,0 @@ |
||||
{ |
||||
"rows": [ |
||||
{ |
||||
"code": "REP", |
||||
"name": "Augur", |
||||
"statuses": [ |
||||
"primary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "BCN", |
||||
"name": "Bytecoin", |
||||
"statuses": [ |
||||
"primary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "BTC", |
||||
"name": "Bitcoin", |
||||
"statuses": [ |
||||
"primary", |
||||
"secondary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "BTS", |
||||
"name": "BitShares", |
||||
"statuses": [ |
||||
"primary", |
||||
"secondary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "BLK", |
||||
"name": "Blackcoin", |
||||
"statuses": [ |
||||
"primary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "GBP", |
||||
"name": "British Pound Sterling", |
||||
"statuses": [ |
||||
"secondary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "CAD", |
||||
"name": "Canadian Dollar", |
||||
"statuses": [ |
||||
"secondary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "CNY", |
||||
"name": "Chinese Yuan", |
||||
"statuses": [ |
||||
"secondary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "DSH", |
||||
"name": "Dashcoin", |
||||
"statuses": [ |
||||
"primary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "DOGE", |
||||
"name": "Dogecoin", |
||||
"statuses": [ |
||||
"primary", |
||||
"secondary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "ETC", |
||||
"name": "Ethereum Classic", |
||||
"statuses": [ |
||||
"primary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "EUR", |
||||
"name": "Euro", |
||||
"statuses": [ |
||||
"primary", |
||||
"secondary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "GNO", |
||||
"name": "GNO", |
||||
"statuses": [ |
||||
"primary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "GNT", |
||||
"name": "GNT", |
||||
"statuses": [ |
||||
"primary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "JPY", |
||||
"name": "Japanese Yen", |
||||
"statuses": [ |
||||
"secondary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "LTC", |
||||
"name": "Litecoin", |
||||
"statuses": [ |
||||
"primary", |
||||
"secondary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "MAID", |
||||
"name": "MaidSafeCoin", |
||||
"statuses": [ |
||||
"primary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "XEM", |
||||
"name": "NEM", |
||||
"statuses": [ |
||||
"primary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "XLM", |
||||
"name": "Stellar", |
||||
"statuses": [ |
||||
"primary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "XMR", |
||||
"name": "Monero", |
||||
"statuses": [ |
||||
"primary", |
||||
"secondary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "XRP", |
||||
"name": "Ripple", |
||||
"statuses": [ |
||||
"primary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "RUR", |
||||
"name": "Ruble", |
||||
"statuses": [ |
||||
"secondary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "STEEM", |
||||
"name": "Steem", |
||||
"statuses": [ |
||||
"primary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "STRAT", |
||||
"name": "STRAT", |
||||
"statuses": [ |
||||
"primary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "UAH", |
||||
"name": "Ukrainian Hryvnia", |
||||
"statuses": [ |
||||
"secondary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "USD", |
||||
"name": "US Dollar", |
||||
"statuses": [ |
||||
"primary", |
||||
"secondary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "WAVES", |
||||
"name": "WAVES", |
||||
"statuses": [ |
||||
"primary" |
||||
] |
||||
}, |
||||
{ |
||||
"code": "ZEC", |
||||
"name": "Zcash", |
||||
"statuses": [ |
||||
"primary" |
||||
] |
||||
} |
||||
] |
||||
} |
@ -0,0 +1,653 @@ |
||||
{ |
||||
"objects": [ |
||||
{ |
||||
"symbol": "ethaud", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "aud", |
||||
"name": "Australian Dollar" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethhkd", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "hkd", |
||||
"name": "Hong Kong Dollar" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethsgd", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "sgd", |
||||
"name": "Singapore Dollar" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethidr", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "idr", |
||||
"name": "Indonesian Rupiah" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethphp", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "php", |
||||
"name": "Philippine Peso" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "eth1st", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "1st", |
||||
"name": "FirstBlood" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethadt", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "adt", |
||||
"name": "adToken" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethadx", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "adx", |
||||
"name": "AdEx" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethant", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "ant", |
||||
"name": "Aragon" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethbat", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "bat", |
||||
"name": "Basic Attention Token" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethbnt", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "bnt", |
||||
"name": "Bancor" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethbtc", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "btc", |
||||
"name": "Bitcoin" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethcad", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "cad", |
||||
"name": "Canadian Dollar" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethcfi", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "cfi", |
||||
"name": "Cofound.it" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethcrb", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "crb", |
||||
"name": "CreditBit" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethcvc", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "cvc", |
||||
"name": "Civic" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethdash", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "dash", |
||||
"name": "Dash" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethdgd", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "dgd", |
||||
"name": "DigixDAO" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethetc", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "etc", |
||||
"name": "Ethereum Classic" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "etheur", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "eur", |
||||
"name": "Euro" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethfun", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "fun", |
||||
"name": "FunFair" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethgbp", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "gbp", |
||||
"name": "Pound Sterling" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethgno", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "gno", |
||||
"name": "Gnosis" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethgnt", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "gnt", |
||||
"name": "Golem" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethgup", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "gup", |
||||
"name": "Matchpool" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethhmq", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "hmq", |
||||
"name": "Humaniq" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethjpy", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "jpy", |
||||
"name": "Japanese Yen" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethlgd", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "lgd", |
||||
"name": "Legends Room" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethlsk", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "lsk", |
||||
"name": "Lisk" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethltc", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "ltc", |
||||
"name": "Litecoin" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethlun", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "lun", |
||||
"name": "Lunyr" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethmco", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "mco", |
||||
"name": "Monaco" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethmtl", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "mtl", |
||||
"name": "Metal" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethmyst", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "myst", |
||||
"name": "Mysterium" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethnmr", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "nmr", |
||||
"name": "Numeraire" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethomg", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "omg", |
||||
"name": "OmiseGO" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethpay", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "pay", |
||||
"name": "TenX" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethptoy", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "ptoy", |
||||
"name": "Patientory" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethqrl", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "qrl", |
||||
"name": "Quantum-Resistant Ledger" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethqtum", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "qtum", |
||||
"name": "Qtum" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethrep", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "rep", |
||||
"name": "Augur" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethrlc", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "rlc", |
||||
"name": "iEx.ec" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethrub", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "rub", |
||||
"name": "Russian Ruble" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethsc", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "sc", |
||||
"name": "Siacoin" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethsngls", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "sngls", |
||||
"name": "SingularDTV" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethsnt", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "snt", |
||||
"name": "Status" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethsteem", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "steem", |
||||
"name": "Steem" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethstorj", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "storj", |
||||
"name": "Storj" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethtime", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "time", |
||||
"name": "ChronoBank" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethtkn", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "tkn", |
||||
"name": "TokenCard" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethtrst", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "trst", |
||||
"name": "WeTrust" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethuah", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "uah", |
||||
"name": "Ukrainian Hryvnia" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethusd", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "usd", |
||||
"name": "United States Dollar" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethwings", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "wings", |
||||
"name": "Wings" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethxem", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "xem", |
||||
"name": "NEM" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethxlm", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "xlm", |
||||
"name": "Stellar Lumen" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethxmr", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "xmr", |
||||
"name": "Monero" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethxrp", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "xrp", |
||||
"name": "Ripple" |
||||
} |
||||
}, |
||||
{ |
||||
"symbol": "ethzec", |
||||
"base": { |
||||
"code": "eth", |
||||
"name": "Ethereum" |
||||
}, |
||||
"quote": { |
||||
"code": "zec", |
||||
"name": "Zcash" |
||||
} |
||||
} |
||||
] |
||||
} |
Loading…
Reference in new issue