Merge branch 'master' into shapeshiftTx

This commit is contained in:
Frankie 2016-08-18 11:06:32 -07:00
commit efa61f2cf8
24 changed files with 401 additions and 15 deletions

View File

@ -1 +1,2 @@
app/scripts/lib/extension-instance.js app/scripts/lib/extension-instance.js
ui/app/conversion-util.js

View File

@ -2,6 +2,10 @@
## Current Master ## Current Master
- Added feature to reflect current conversion rates of current vault balance.
## 2.8.0 2016-08-15
- Integrate ShapeShift - Integrate ShapeShift
- Add a for for Coinbase to specify amount to buy - Add a for for Coinbase to specify amount to buy
- Fix various typos. - Fix various typos.

1
app/currencies.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,7 @@
{ {
"name": "MetaMask", "name": "MetaMask",
"short_name": "Metamask", "short_name": "Metamask",
"version": "2.7.3", "version": "2.8.0",
"manifest_version": 2, "manifest_version": 2,
"description": "Ethereum Browser Extension", "description": "Ethereum Browser Extension",
"icons": { "icons": {

View File

@ -1,6 +1,7 @@
const Migrator = require('pojo-migrator') const Migrator = require('pojo-migrator')
const MetamaskConfig = require('../config.js') const MetamaskConfig = require('../config.js')
const migrations = require('./migrations') const migrations = require('./migrations')
const rp = require('request-promise')
const TESTNET_RPC = MetamaskConfig.network.testnet const TESTNET_RPC = MetamaskConfig.network.testnet
const MAINNET_RPC = MetamaskConfig.network.mainnet const MAINNET_RPC = MetamaskConfig.network.mainnet
@ -270,6 +271,53 @@ ConfigManager.prototype.getConfirmed = function () {
return ('isConfirmed' in data) && data.isConfirmed return ('isConfirmed' in data) && data.isConfirmed
} }
ConfigManager.prototype.setCurrentFiat = function (currency) {
var data = this.getData()
data.fiatCurrency = currency
this.setData(data)
}
ConfigManager.prototype.getCurrentFiat = function () {
var data = this.getData()
return ('fiatCurrency' in data) && data.fiatCurrency
}
ConfigManager.prototype.updateConversionRate = function () {
var data = this.getData()
return rp(`https://www.cryptonator.com/api/ticker/eth-${data.fiatCurrency}`)
.then((response) => {
const parsedResponse = JSON.parse(response)
this.setConversionPrice(parsedResponse.ticker.price)
this.setConversionDate(parsedResponse.timestamp)
}).catch((err) => {
console.error('Error in conversion.', err)
this.setConversionPrice(0)
this.setConversionDate('N/A')
})
}
ConfigManager.prototype.setConversionPrice = function(price) {
var data = this.getData()
data.conversionRate = Number(price)
this.setData(data)
}
ConfigManager.prototype.setConversionDate = function (datestring) {
var data = this.getData()
data.conversionDate = datestring
this.setData(data)
}
ConfigManager.prototype.getConversionRate = function () {
var data = this.getData()
return (('conversionRate' in data) && data.conversionRate) || 0
}
ConfigManager.prototype.getConversionDate = function () {
var data = this.getData()
return (('conversionDate' in data) && data.conversionDate) || 'N/A'
}
ConfigManager.prototype.setShouldntShowWarning = function () { ConfigManager.prototype.setShouldntShowWarning = function () {
var data = this.getData() var data = this.getData()
if (data.isEthConfirmed) { if (data.isEthConfirmed) {

View File

@ -101,6 +101,9 @@ IdentityStore.prototype.getState = function () {
messages: messageManager.getMsgList(), messages: messageManager.getMsgList(),
selectedAddress: configManager.getSelectedAccount(), selectedAddress: configManager.getSelectedAccount(),
shapeShiftTxList: configManager.getShapeShiftTxList(), shapeShiftTxList: configManager.getShapeShiftTxList(),
currentFiat: configManager.getCurrentFiat(),
conversionRate: configManager.getConversionRate(),
conversionDate: configManager.getConversionDate(),
})) }))
} }

View File

@ -21,6 +21,9 @@ module.exports = class MetamaskController {
this.idStore.setStore(this.ethStore) this.idStore.setStore(this.ethStore)
this.messageManager = messageManager this.messageManager = messageManager
this.publicConfigStore = this.initPublicConfigStore() this.publicConfigStore = this.initPublicConfigStore()
this.configManager.setCurrentFiat('USD')
this.configManager.updateConversionRate()
this.scheduleConversionInterval()
} }
getState () { getState () {
@ -40,7 +43,9 @@ module.exports = class MetamaskController {
setProviderType: this.setProviderType.bind(this), setProviderType: this.setProviderType.bind(this),
useEtherscanProvider: this.useEtherscanProvider.bind(this), useEtherscanProvider: this.useEtherscanProvider.bind(this),
agreeToDisclaimer: this.agreeToDisclaimer.bind(this), agreeToDisclaimer: this.agreeToDisclaimer.bind(this),
setCurrentFiat: this.setCurrentFiat.bind(this),
agreeToEthWarning: this.agreeToEthWarning.bind(this), agreeToEthWarning: this.agreeToEthWarning.bind(this),
// forward directly to idStore // forward directly to idStore
createNewVault: idStore.createNewVault.bind(idStore), createNewVault: idStore.createNewVault.bind(idStore),
recoverFromSeed: idStore.recoverFromSeed.bind(idStore), recoverFromSeed: idStore.recoverFromSeed.bind(idStore),
@ -247,6 +252,31 @@ module.exports = class MetamaskController {
} }
} }
setCurrentFiat (fiat, cb) {
try {
this.configManager.setCurrentFiat(fiat)
this.configManager.updateConversionRate()
this.scheduleConversionInterval()
const data = {
conversionRate: this.configManager.getConversionRate(),
currentFiat: this.configManager.getCurrentFiat(),
conversionDate: this.configManager.getConversionDate(),
}
cb(data)
} catch (e) {
cb(null, e)
}
}
scheduleConversionInterval () {
if (this.conversionInterval) {
clearInterval(this.conversionInterval)
}
this.conversionInterval = setInterval(() => {
this.configManager.updateConversionRate()
}, 300000)
}
agreeToEthWarning (cb) { agreeToEthWarning (cb) {
try { try {
this.configManager.setShouldntShowWarning() this.configManager.setShouldntShowWarning()

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,8 @@
{ {
"metamask": { "metamask": {
"currentFiat": "USD",
"conversionRate": 11.06608791,
"conversionDate": 1470421024,
"isInitialized": true, "isInitialized": true,
"isUnlocked": true, "isUnlocked": true,
"currentDomain": "example.com", "currentDomain": "example.com",
@ -72,7 +75,7 @@
"txParams": { "txParams": {
"from": "0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc", "from": "0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc",
"to": "0x18a3462427bcc9133bb46e88bcbe39cd7ef0e761", "to": "0x18a3462427bcc9133bb46e88bcbe39cd7ef0e761",
"value": "0x99966c8104aa57038000", "value": "0x0",
"origin": "thelongestdomainnameintheworldandthensomeandthensomemoreandmore.com", "origin": "thelongestdomainnameintheworldandthensomeandthensomemoreandmore.com",
"metamaskId": 1467923203344608, "metamaskId": 1467923203344608,
"metamaskNetworkId": "2" "metamaskNetworkId": "2"

View File

@ -1,5 +1,8 @@
{ {
"metamask": { "metamask": {
"currentFiat": "USD",
"conversionRate": 11.06608791,
"conversionDate": 1470421024,
"isInitialized": true, "isInitialized": true,
"isUnlocked": true, "isUnlocked": true,
"currentDomain": "example.com", "currentDomain": "example.com",

View File

@ -1,5 +1,8 @@
{ {
"metamask": { "metamask": {
"currentFiat": "USD",
"conversionRate": 11.06608791,
"conversionDate": 1470421024,
"isInitialized": true, "isInitialized": true,
"isUnlocked": true, "isUnlocked": true,
"currentDomain": "example.com", "currentDomain": "example.com",
@ -68,7 +71,7 @@
"appState": { "appState": {
"menuOpen": false, "menuOpen": false,
"currentView": { "currentView": {
"name": "accounts" "name": "config"
}, },
"accountDetail": { "accountDetail": {
"subview": "transactions", "subview": "transactions",

View File

@ -67,6 +67,7 @@
"redux": "^3.0.5", "redux": "^3.0.5",
"redux-logger": "^2.3.1", "redux-logger": "^2.3.1",
"redux-thunk": "^1.0.2", "redux-thunk": "^1.0.2",
"request-promise": "^4.1.1",
"sandwich-expando": "^1.0.5", "sandwich-expando": "^1.0.5",
"textarea-caret": "^3.0.1", "textarea-caret": "^3.0.1",
"three.js": "^0.73.2", "three.js": "^0.73.2",

View File

@ -3,6 +3,7 @@ const extend = require('xtend')
const STORAGE_KEY = 'metamask-persistance-key' const STORAGE_KEY = 'metamask-persistance-key'
var configManagerGen = require('../lib/mock-config-manager') var configManagerGen = require('../lib/mock-config-manager')
var configManager var configManager
const rp = require('request-promise')
describe('config-manager', function() { describe('config-manager', function() {
@ -11,6 +12,81 @@ describe('config-manager', function() {
configManager = configManagerGen() configManager = configManagerGen()
}) })
describe('currency conversions', function() {
describe('#getCurrentFiat', function() {
it('should return false if no previous key exists', function() {
var result = configManager.getCurrentFiat()
assert.ok(!result)
})
})
describe('#setCurrentFiat', function() {
it('should make getCurrentFiat return true once set', function() {
assert.equal(configManager.getCurrentFiat(), false)
configManager.setCurrentFiat('USD')
var result = configManager.getCurrentFiat()
assert.equal(result, 'USD')
})
it('should work with other currencies as well', function() {
assert.equal(configManager.getCurrentFiat(), false)
configManager.setCurrentFiat('JPY')
var result = configManager.getCurrentFiat()
assert.equal(result, 'JPY')
})
})
describe('#getConversionRate', function() {
it('should return false if non-existent', function() {
var result = configManager.getConversionRate()
assert.ok(!result)
})
})
describe('#updateConversionRate', function() {
it('should retrieve an update for ETH to USD and set it in memory', function(done) {
this.timeout(15000)
assert.equal(configManager.getConversionRate(), false)
var promise = new Promise(
function (resolve, reject) {
configManager.setCurrentFiat('USD')
configManager.updateConversionRate().then(function() {
resolve()
})
})
promise.then(function() {
var result = configManager.getConversionRate()
assert.equal(typeof result, 'number')
done()
}).catch(function(err) {
console.log(err)
})
})
it('should work for JPY as well.', function() {
this.timeout(15000)
assert.equal(configManager.getConversionRate(), false)
var promise = new Promise(
function (resolve, reject) {
configManager.setCurrentFiat('JPY')
configManager.updateConversionRate().then(function() {
resolve()
})
})
promise.then(function() {
var result = configManager.getConversionRate()
assert.equal(typeof result, 'number')
}).catch(function(err) {
console.log(err)
})
})
})
})
describe('confirmation', function() { describe('confirmation', function() {
describe('#getConfirmed', function() { describe('#getConfirmed', function() {
@ -215,4 +291,3 @@ describe('config-manager', function() {
}) })
}) })
}) })

View File

@ -9,7 +9,7 @@ const ReactCSSTransitionGroup = require('react-addons-css-transition-group')
const valuesFor = require('./util').valuesFor const valuesFor = require('./util').valuesFor
const Identicon = require('./components/identicon') const Identicon = require('./components/identicon')
const EtherBalance = require('./components/eth-balance') const AccountEtherBalance = require('./components/account-eth-balance')
const TransactionList = require('./components/transaction-list') const TransactionList = require('./components/transaction-list')
const ExportAccountView = require('./components/account-export') const ExportAccountView = require('./components/account-export')
const ethUtil = require('ethereumjs-util') const ethUtil = require('ethereumjs-util')
@ -20,6 +20,7 @@ module.exports = connect(mapStateToProps)(AccountDetailScreen)
function mapStateToProps (state) { function mapStateToProps (state) {
return { return {
metamask: state.metamask,
identities: state.metamask.identities, identities: state.metamask.identities,
accounts: state.metamask.accounts, accounts: state.metamask.accounts,
address: state.metamask.selectedAccount, address: state.metamask.selectedAccount,
@ -163,9 +164,8 @@ AccountDetailScreen.prototype.render = function () {
}, },
}, [ }, [
h(EtherBalance, { h(AccountEtherBalance, {
value: account && account.balance, value: account && account.balance,
mainBalance: true,
style: { style: {
lineHeight: '7px', lineHeight: '7px',
marginTop: '10px', marginTop: '10px',
@ -255,6 +255,7 @@ AccountDetailScreen.prototype.requestAccountExport = function () {
this.props.dispatch(actions.requestExportAccount()) this.props.dispatch(actions.requestExportAccount())
} }
AccountDetailScreen.prototype.buyButtonDeligator = function () { AccountDetailScreen.prototype.buyButtonDeligator = function () {
var props = this.props var props = this.props

View File

@ -3,7 +3,7 @@ const h = require('react-hyperscript')
const inherits = require('util').inherits const inherits = require('util').inherits
const ethUtil = require('ethereumjs-util') const ethUtil = require('ethereumjs-util')
const EtherBalance = require('../components/eth-balance') const AccountEtherBalance = require('../components/account-eth-balance')
const CopyButton = require('../components/copyButton') const CopyButton = require('../components/copyButton')
const Identicon = require('../components/identicon') const Identicon = require('../components/identicon')
@ -50,8 +50,12 @@ NewComponent.prototype.render = function () {
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
}, },
}, ethUtil.toChecksumAddress(identity.address)), }, ethUtil.toChecksumAddress(identity.address)),
h(EtherBalance, { h(AccountEtherBalance, {
value: account.balance, value: account.balance,
style: {
lineHeight: '7px',
marginTop: '10px',
},
}), }),
]), ]),

View File

@ -55,6 +55,8 @@ var actions = {
SHOW_CONF_MSG_PAGE: 'SHOW_CONF_MSG_PAGE', SHOW_CONF_MSG_PAGE: 'SHOW_CONF_MSG_PAGE',
REVEAL_ACCOUNT: 'REVEAL_ACCOUNT', REVEAL_ACCOUNT: 'REVEAL_ACCOUNT',
revealAccount: revealAccount, revealAccount: revealAccount,
SET_CURRENT_FIAT: 'SET_CURRENT_FIAT',
setCurrentFiat: setCurrentFiat,
// account detail screen // account detail screen
SHOW_SEND_PAGE: 'SHOW_SEND_PAGE', SHOW_SEND_PAGE: 'SHOW_SEND_PAGE',
showSendPage: showSendPage, showSendPage: showSendPage,
@ -236,6 +238,23 @@ function revealAccount () {
} }
} }
function setCurrentFiat (fiat) {
return (dispatch) => {
dispatch(this.showLoadingIndication())
_accountManager.setCurrentFiat(fiat, (data, err) => {
dispatch(this.hideLoadingIndication())
dispatch({
type: this.SET_CURRENT_FIAT,
value: {
currentFiat: data.currentFiat,
conversionRate: data.conversionRate,
conversionDate: data.conversionDate,
},
})
})
}
}
function signMsg (msgData) { function signMsg (msgData) {
return (dispatch) => { return (dispatch) => {
dispatch(actions.showLoadingIndication()) dispatch(actions.showLoadingIndication())

View File

@ -0,0 +1,139 @@
const Component = require('react').Component
const h = require('react-hyperscript')
const inherits = require('util').inherits
const connect = require('react-redux').connect
const formatBalance = require('../util').formatBalance
const generateBalanceObject = require('../util').generateBalanceObject
const Tooltip = require('./tooltip.js')
module.exports = connect(mapStateToProps)(EthBalanceComponent)
function mapStateToProps (state) {
return {
conversionRate: state.metamask.conversionRate,
conversionDate: state.metamask.conversionDate,
currentFiat: state.metamask.currentFiat,
}
}
inherits(EthBalanceComponent, Component)
function EthBalanceComponent () {
Component.call(this)
}
EthBalanceComponent.prototype.render = function () {
var state = this.props
var style = state.style
const value = formatBalance(state.value, 6)
var width = state.width
return (
h('.ether-balance', {
style: style,
}, [
h('.ether-balance-amount', {
style: {
display: 'inline',
width: width,
},
}, this.renderBalance(value, state)),
])
)
}
EthBalanceComponent.prototype.renderBalance = function (value, state) {
if (value === 'None') return value
var balanceObj = generateBalanceObject(value, state.shorten ? 1 : 3)
var balance, fiatNumber
var splitBalance = value.split(' ')
var ethNumber = splitBalance[0]
var ethSuffix = splitBalance[1]
if (state.conversionRate !== 0) {
fiatNumber = (Number(splitBalance[0]) * state.conversionRate).toFixed(2)
} else {
fiatNumber = 'N/A'
}
var fiatSuffix = state.currentFiat
if (state.shorten) {
balance = balanceObj.shortBalance
} else {
balance = balanceObj.balance
}
var label = balanceObj.label
return (
h('.flex-column', [
h(Tooltip, {
position: 'bottom',
title: `${ethNumber} ${ethSuffix}`,
}, [
h('.flex-row', {
style: {
alignItems: 'flex-end',
lineHeight: '13px',
fontFamily: 'Montserrat Light',
textRendering: 'geometricPrecision',
marginBottom: '5px',
},
}, [
h('div', {
style: {
width: '100%',
textAlign: 'right',
},
}, balance),
h('div', {
style: {
color: '#AEAEAE',
marginLeft: '5px',
},
}, label),
]),
]),
h(Tooltip, {
position: 'bottom',
title: `${fiatNumber} ${fiatSuffix}`,
}, [
fiatDisplay(fiatNumber, fiatSuffix),
]),
])
)
}
function fiatDisplay (fiatNumber, fiatSuffix) {
if (fiatNumber !== 'N/A') {
return h('.flex-row', {
style: {
alignItems: 'flex-end',
lineHeight: '13px',
fontFamily: 'Montserrat Light',
textRendering: 'geometricPrecision',
},
}, [
h('div', {
style: {
width: '100%',
textAlign: 'right',
fontSize: '12px',
color: '#333333',
},
}, fiatNumber),
h('div', {
style: {
color: '#AEAEAE',
marginLeft: '5px',
fontSize: '12px',
},
}, fiatSuffix),
])
} else {
return h('div')
}
}

View File

@ -110,6 +110,9 @@ BuyButtonSubview.prototype.formVersionSubview = function () {
h('h3.text-transform-uppercase', 'or:'), h('h3.text-transform-uppercase', 'or:'),
this.props.network === '2' ? h('button.text-transform-uppercase', { this.props.network === '2' ? h('button.text-transform-uppercase', {
onClick: () => this.props.dispatch(actions.buyEth()), onClick: () => this.props.dispatch(actions.buyEth()),
style: {
marginTop: '15px',
},
}, 'Go To Test Faucet') : null, }, 'Go To Test Faucet') : null,
]) ])
} }

View File

@ -4,6 +4,7 @@ const inherits = require('util').inherits
const formatBalance = require('../util').formatBalance const formatBalance = require('../util').formatBalance
const generateBalanceObject = require('../util').generateBalanceObject const generateBalanceObject = require('../util').generateBalanceObject
const Tooltip = require('./tooltip.js') const Tooltip = require('./tooltip.js')
module.exports = EthBalanceComponent module.exports = EthBalanceComponent
inherits(EthBalanceComponent, Component) inherits(EthBalanceComponent, Component)
@ -37,6 +38,9 @@ EthBalanceComponent.prototype.renderBalance = function (value, state) {
if (value === 'None') return value if (value === 'None') return value
var balanceObj = generateBalanceObject(value, state.shorten ? 1 : 3) var balanceObj = generateBalanceObject(value, state.shorten ? 1 : 3)
var balance var balance
var splitBalance = value.split(' ')
var ethNumber = splitBalance[0]
var ethSuffix = splitBalance[1]
if (state.shorten) { if (state.shorten) {
balance = balanceObj.shortBalance balance = balanceObj.shortBalance
@ -49,7 +53,7 @@ EthBalanceComponent.prototype.renderBalance = function (value, state) {
return ( return (
h(Tooltip, { h(Tooltip, {
position: 'bottom', position: 'bottom',
title: value.split(' ')[0], title: `${ethNumber} ${ethSuffix}`,
}, [ }, [
h('.flex-column', { h('.flex-column', {
style: { style: {

View File

@ -52,7 +52,11 @@ QrCodeView.prototype.render = function () {
}, },
}), }),
h('.flex-row', [ h('.flex-row', [
h('h3.ellip-address', Qr.data), h('h3.ellip-address', {
style: {
width: '247px',
},
}, Qr.data),
h(CopyButton, { h(CopyButton, {
value: Qr.data, value: Qr.data,
}), }),

View File

@ -3,7 +3,7 @@ const Component = require('react').Component
const h = require('react-hyperscript') const h = require('react-hyperscript')
const connect = require('react-redux').connect const connect = require('react-redux').connect
const actions = require('./actions') const actions = require('./actions')
const currencies = require('./conversion-util').availableCurrencies.rows
module.exports = connect(mapStateToProps)(ConfigScreen) module.exports = connect(mapStateToProps)(ConfigScreen)
function mapStateToProps (state) { function mapStateToProps (state) {
@ -74,6 +74,8 @@ ConfigScreen.prototype.render = function () {
}, 'Save'), }, 'Save'),
]), ]),
h('hr.horizontal-line'), h('hr.horizontal-line'),
currentConversionInformation(metamaskState, state),
h('hr.horizontal-line'),
h('div', { h('div', {
style: { style: {
@ -97,6 +99,27 @@ ConfigScreen.prototype.render = function () {
) )
} }
function currentConversionInformation (metamaskState, state) {
var currentFiat = metamaskState.currentFiat
var conversionDate = metamaskState.conversionDate
return h('div', [
h('span', {style: { fontWeight: 'bold', paddingRight: '10px'}}, 'Current Conversion'),
h('span', {style: { fontWeight: 'bold', paddingRight: '10px', fontSize: '13px'}}, `Updated ${Date(conversionDate)}`),
h('select#currentFiat', {
onChange (event) {
event.preventDefault()
var element = document.getElementById('currentFiat')
var newFiat = element.value
state.dispatch(actions.setCurrentFiat(newFiat))
},
defaultValue: currentFiat,
}, currencies.map((currency) => {
return h('option', {key: currency.code, value: currency.code}, `${currency.code} - ${currency.name}`)
})
),
])
}
function currentProviderDisplay (metamaskState) { function currentProviderDisplay (metamaskState) {
var provider = metamaskState.provider var provider = metamaskState.provider
var title, value var title, value

File diff suppressed because one or more lines are too long

View File

@ -15,6 +15,9 @@ function reduceMetamask (state, action) {
rpcTarget: 'https://rawtestrpc.metamask.io/', rpcTarget: 'https://rawtestrpc.metamask.io/',
identities: {}, identities: {},
unconfTxs: {}, unconfTxs: {},
currentFiat: 'USD',
conversionRate: 0,
conversionDate: 'N/A',
}, state.metamask) }, state.metamask)
switch (action.type) { switch (action.type) {
@ -115,6 +118,13 @@ function reduceMetamask (state, action) {
var identities = extend(metamaskState.identities, id) var identities = extend(metamaskState.identities, id)
return extend(metamaskState, { identities }) return extend(metamaskState, { identities })
case actions.SET_CURRENT_FIAT:
return extend(metamaskState, {
currentFiat: action.value.currentFiat,
conversionRate: action.value.conversionRate,
conversionDate: action.value.conversionDate,
})
default: default:
return metamaskState return metamaskState

View File

@ -145,6 +145,8 @@ function shortenBalance (balance, decimalsToKeep = 1) {
} else if (convertedBalance > 1000) { } else if (convertedBalance > 1000) {
truncatedValue = (balance / 1000).toFixed(decimalsToKeep) truncatedValue = (balance / 1000).toFixed(decimalsToKeep)
return `>${truncatedValue}k` return `>${truncatedValue}k`
} else if (convertedBalance === 0) {
return '0'
} else if (convertedBalance < 1) { } else if (convertedBalance < 1) {
var exponent = balance.match(/\.0*/)[0].length var exponent = balance.match(/\.0*/)[0].length
truncatedValue = (convertedBalance * Math.pow(10, exponent)).toFixed(decimalsToKeep) truncatedValue = (convertedBalance * Math.pow(10, exponent)).toFixed(decimalsToKeep)