Commit 6cc59cfd authored by Luke Schoen's avatar Luke Schoen
Browse files

feat: Send transaction with data field and allow amount to be zero

* Update Send transaction UI so "amount" field can be 0, since some users may want to just send some data (i.e. https://github.com/chainx-org/ChainX/issues/66)
* Set the default amount value to be 0 and not required
* Update Send transaction UI to support "data" field in hex (with 0x prefix)
* Use a hard-coded Gas Limit of 200,000 when user provides hex "data" value
* Remove logic that doesn't make sense (i.e. `else if (amountBn.isZero()) {`)
* Update validation messages
* Add support for Blockscout link when user using Goerli testnet
* Update internationalisation
* Use custom `isHexString` function instead of the ethereumjs-util's version. The ethereumjs-util's version is used by MyCrypto and doesn't work properly.
* Tested on Goerli testnet by creating two accounts, requesting Goerli tokens, running with `--chain goerli` appended to the end (i.e. `"start": "cross-env ELECTRON_START_URL=http://localhost:3000 electron-webpack dev --chain goerli",` in fether/packages/fether-electron/package.json), and sending a transaction with the high gas price, the hex data. For example, for ChainX convert your ChainX Address (UTF8 chars) into Hex code (i.e. copy/paste it at https://www.browserling.com/tools/utf8-to-hex, for example "helloworld" becomes => `\x68\x65\x6c\x6c\x6f\x20\x77\x6f\x72\x6c\x64`, and remove the `\x`, => `68656c6c6f20776f726c64` => then prepend 0x => `0x68656c6c6f20776f726c64` and add that to the "data" field), then send the transaction, and
when the transaction is successful go to the Blockscout link and you'll see under "Raw Input" ("Input Data" on Etherscan) that you can switch between viewing it in UTF8 or Hex, i.e. https://blockscout.com/eth/goerli/tx/0x7eaec61ce7753fd4c80aec4509c49942b53986585e4864e18134806bffb25f10/internal_transactions)
* TODO - update Parity Signer to show "data" field value if it doesn't already
parent 70b339b2
Pipeline #38913 passed with stage
in 2 minutes and 1 second
...@@ -37,7 +37,7 @@ ...@@ -37,7 +37,7 @@
"package": "electron-builder", "package": "electron-builder",
"prerelease": "./scripts/revertElectronBug.sh", "prerelease": "./scripts/revertElectronBug.sh",
"release": "electron-builder", "release": "electron-builder",
"start": "cross-env ELECTRON_START_URL=http://localhost:3000 electron-webpack dev", "start": "cross-env ELECTRON_START_URL=http://localhost:3000 electron-webpack dev --chain goerli",
"test": "jest --all --color --coverage" "test": "jest --all --color --coverage"
}, },
"dependencies": { "dependencies": {
......
...@@ -47,6 +47,7 @@ ...@@ -47,6 +47,7 @@
"bip39": "^2.5.0", "bip39": "^2.5.0",
"debounce-promise": "^3.1.0", "debounce-promise": "^3.1.0",
"debug": "^4.1.0", "debug": "^4.1.0",
"ethjs-util": "^0.1.6",
"ethereumjs-tx": "^1.3.7", "ethereumjs-tx": "^1.3.7",
"ethereumjs-util": "^6.0.0", "ethereumjs-util": "^6.0.0",
"ethereumjs-wallet": "^0.6.2", "ethereumjs-wallet": "^0.6.2",
......
...@@ -78,6 +78,7 @@ class SignedTxSummary extends Component { ...@@ -78,6 +78,7 @@ class SignedTxSummary extends Component {
<Form <Form
key='txForm' key='txForm'
initialValues={{ initialValues={{
data: tx.data,
from: address, from: address,
to: tx.to, to: tx.to,
amount: `${tx.amount} ${token.symbol}`, amount: `${tx.amount} ${token.symbol}`,
...@@ -104,6 +105,14 @@ class SignedTxSummary extends Component { ...@@ -104,6 +105,14 @@ class SignedTxSummary extends Component {
render={FetherForm.Field} render={FetherForm.Field}
/> />
<Field
className='form_field_value'
disabled
label={i18n.t(`${packageNS}:tx.form.field.data`)}
name='data'
render={FetherForm.Field}
/>
{values.to === values.from && ( {values.to === values.from && (
<span> <span>
<h3> <h3>
......
...@@ -17,7 +17,8 @@ class TxDetails extends Component { ...@@ -17,7 +17,8 @@ class TxDetails extends Component {
if ( if (
!estimatedTxFee || !estimatedTxFee ||
!values.gasPrice || !values.gasPrice ||
!values.amount || // Allow estimating tx fee when the amount is zero
// !values.amount ||
!values.chainId || !values.chainId ||
!values.ethBalance || !values.ethBalance ||
!values.gas || !values.gas ||
...@@ -43,6 +44,17 @@ ${this.renderTotalAmount()}`; ...@@ -43,6 +44,17 @@ ${this.renderTotalAmount()}`;
} }
const gasPriceBn = new BigNumber(values.gasPrice.toString()); const gasPriceBn = new BigNumber(values.gasPrice.toString());
// Temporarily solution since Fether has to have the simplest UI
// a Gas Price form field may be too overwhelming for beginner users
// but advanced users may want to send data that may require a
// higher Gas Limit. On
if (values.data) {
return i18n.t(`${packageNS}:tx.form.details.gas_limit`, {
gas_limit: new BigNumber(200000)
});
}
const gasLimitBn = estimatedTxFee const gasLimitBn = estimatedTxFee
.div(gasPriceBn) .div(gasPriceBn)
.div(10 ** 9) .div(10 ** 9)
...@@ -76,14 +88,14 @@ ${this.renderTotalAmount()}`; ...@@ -76,14 +88,14 @@ ${this.renderTotalAmount()}`;
const { estimatedTxFee, token, values } = this.props; const { estimatedTxFee, token, values } = this.props;
const currentChainIdBN = values.chainId; const currentChainIdBN = values.chainId;
if (!estimatedTxFee || !values.amount || !token.address) { if (!estimatedTxFee || !token.address) {
return; return;
} }
const totalAmount = `${fromWei( const totalAmount = `${fromWei(
estimatedTxFee.plus( estimatedTxFee.plus(
isNotErc20TokenAddress(token.address) isNotErc20TokenAddress(token.address)
? toWei(values.amount.toString()) ? values.amount && toWei(values.amount.toString())
: 0 : 0
), ),
'ether' 'ether'
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import BigNumber from 'bignumber.js'; import BigNumber from 'bignumber.js';
// import { isHexString } from 'ethereumjs-util';
import { chainId$, transactionCountOf$ } from '@parity/light.js'; import { chainId$, transactionCountOf$ } from '@parity/light.js';
import { Clickable, Form as FetherForm, Header } from 'fether-ui'; import { Clickable, Form as FetherForm, Header } from 'fether-ui';
import createDecorator from 'final-form-calculate'; import createDecorator from 'final-form-calculate';
...@@ -38,9 +39,50 @@ const DEFAULT_AMOUNT_MAX_CHARS = 9; ...@@ -38,9 +39,50 @@ const DEFAULT_AMOUNT_MAX_CHARS = 9;
const MEDIUM_AMOUNT_MAX_CHARS = 14; const MEDIUM_AMOUNT_MAX_CHARS = 14;
const MAX_GAS_PRICE = 40; // In Gwei const MAX_GAS_PRICE = 40; // In Gwei
const MIN_GAS_PRICE = 3; // Safelow gas price from GasStation, in Gwei const MIN_GAS_PRICE = 3; // Safelow gas price from GasStation, in Gwei
// Reference: https://ethereum.stackexchange.com/questions/1106/is-there-a-limit-for-transaction-size
const ESTIMATED_MAX_TOTAL_FEE = 0.002; // In ETH
const debug = Debug('TxForm'); const debug = Debug('TxForm');
// Luke's custom `isHexString`.
// See https://github.com/MyCryptoHQ/MyCrypto/issues/2592#issuecomment-496432227
function isHexString (value, length) {
if (typeof value !== 'string') {
return false;
}
// Check for hex prefix
if (value.substring(0, 2) !== '0x') {
return false;
}
// Extract hex value without the 0x prefix
// TODO - possible replace with `stripHexPrefix(value).
// See https://github.com/ethjs/ethjs-util/blob/master/dist/ethjs-util.js#L2120
const postfixValue = value.slice(2);
if (!postfixValue.length) {
console.log('No characters found after 0x prefix');
return false;
}
// Check if non-hex character exists after 0x prefix
const match = /[^0-9A-Fa-f]/.exec(postfixValue);
if (match) {
console.log('Non-hex character match found at ' + match.index);
return false;
}
// Check if the hex string is the expected length
if (length && postfixValue.length !== length) {
console.log(
`Hex value length is ${postfixValue.length} but should be ${length}`
);
return false;
}
return true;
}
@inject('parityStore', 'sendStore') @inject('parityStore', 'sendStore')
@withTokens @withTokens
@withProps(({ match: { params: { tokenAddress } }, tokens }) => ({ @withProps(({ match: { params: { tokenAddress } }, tokens }) => ({
...@@ -69,6 +111,20 @@ class TxForm extends Component { ...@@ -69,6 +111,20 @@ class TxForm extends Component {
}; };
decorator = createDecorator({ decorator = createDecorator({
// FIXME - Why can't we estimate gas when we update the next line to
// include the `data` (i.e. `field: /data|to|amount/,`).
// If we do then it gives error in console:
// ```
// eth_estimateGas([{"data":"0x0000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaffffffffffff",
// ... "value":"0x0"}]): -32015: Transaction execution error.
// ```
//
// And in the UI's "Transaction Details" section it displays:
// ```
// Gas Limit: -1
// Fee: -0.000000004 ETH (gas limit * gas price)
// Total Amount: -4e-9 ETH
// ```
field: /to|amount/, // when the value of these fields change... field: /to|amount/, // when the value of these fields change...
updates: { updates: {
// ...set field "gas" // ...set field "gas"
...@@ -125,7 +181,8 @@ class TxForm extends Component { ...@@ -125,7 +181,8 @@ class TxForm extends Component {
isEstimatedTxFee = values => { isEstimatedTxFee = values => {
if ( if (
values.amount && // Allow estimating tx fee when the amount is zero
// values.amount &&
values.gas && values.gas &&
values.gasPrice && values.gasPrice &&
!isNaN(values.amount) && !isNaN(values.amount) &&
...@@ -242,6 +299,7 @@ class TxForm extends Component { ...@@ -242,6 +299,7 @@ class TxForm extends Component {
<Form <Form
decorators={[this.decorator]} decorators={[this.decorator]}
initialValues={{ initialValues={{
amount: 0,
chainId, chainId,
ethBalance, ethBalance,
from: address, from: address,
...@@ -303,7 +361,6 @@ class TxForm extends Component { ...@@ -303,7 +361,6 @@ class TxForm extends Component {
name='amount' name='amount'
placeholder='0.00' placeholder='0.00'
render={FetherForm.Field} render={FetherForm.Field}
required
type='number' type='number'
> >
<button <button
...@@ -322,6 +379,15 @@ class TxForm extends Component { ...@@ -322,6 +379,15 @@ class TxForm extends Component {
</button> </button>
</Field> </Field>
<Field
as='textarea'
className='-sm'
label={i18n.t(`${packageNS}:tx.form.field.data`)}
name='data'
placeholder='0x...'
render={FetherForm.Field}
/>
<Field <Field
centerText={`${values.gasPrice} GWEI`} centerText={`${values.gasPrice} GWEI`}
className='-range' className='-range'
...@@ -356,6 +422,14 @@ class TxForm extends Component { ...@@ -356,6 +422,14 @@ class TxForm extends Component {
}} }}
</OnChange> </OnChange>
<OnChange name='data'>
{(value, previous) => {
if (this.state.maxSelected) {
mutators.recalculateMax();
}
}}
</OnChange>
{values.to === values.from && ( {values.to === values.from && (
<span> <span>
<h3> <h3>
...@@ -418,41 +492,27 @@ class TxForm extends Component { ...@@ -418,41 +492,27 @@ class TxForm extends Component {
return; return;
} }
if (!values.amount) {
return {
amount: i18n.t(`${packageNS}:tx.form.validation.amount_invalid`)
};
}
const amountBn = new BigNumber(values.amount.toString()); const amountBn = new BigNumber(values.amount.toString());
if (amountBn.isNaN()) { if (amountBn.isNaN()) {
return { return {
amount: i18n.t(`${packageNS}:tx.form.validation.amount_invalid`) amount: i18n.t(`${packageNS}:tx.form.validation.amount_invalid`)
}; };
} else if (amountBn.isZero()) {
if (this.state.maxSelected) {
return {
amount: i18n.t(
`${packageNS}:tx.form.validation.eth_balance_too_low_for_gas`,
{ chain_id: chainIdToString(currentChainIdBN) }
)
};
}
return {
amount: i18n.t(`${packageNS}:tx.form.validation.non_zero_amount`)
};
} else if (amountBn.isNegative()) { } else if (amountBn.isNegative()) {
return { return {
amount: i18n.t(`${packageNS}:tx.form.validation.positive_amount`) amount: i18n.t(`${packageNS}:tx.form.validation.positive_amount`)
}; };
} else if ( // Question: Why do we require a minimum value in wei when users may often
isNotErc20TokenAddress(token.address) && // send transactions with a value of say 0 ETH and a "data" value instead.
toWei(values.amount).lt(1) // For example: https://github.com/chainx-org/ChainX/issues/66
) {
return { // } else if (
amount: i18n.t(`${packageNS}:tx.form.validation.min_wei`) // isNotErc20TokenAddress(token.address) &&
}; // toWei(values.amount).lt(1)
// ) {
// return {
// amount: i18n.t(`${packageNS}:tx.form.validation.min_wei`)
// };
} else if (amountBn.dp() > token.decimals) { } else if (amountBn.dp() > token.decimals) {
return { return {
amount: i18n.t(`${packageNS}:tx.form.validation.min_decimals`, { amount: i18n.t(`${packageNS}:tx.form.validation.min_decimals`, {
...@@ -460,7 +520,19 @@ class TxForm extends Component { ...@@ -460,7 +520,19 @@ class TxForm extends Component {
token_decimals: token.decimals token_decimals: token.decimals
}) })
}; };
} else if (balance && balance.lt(amountBn)) { // Transaction without any "data"
} else if (!values.data && balance && balance.lt(amountBn)) {
return {
amount: i18n.t(
`${packageNS}:tx.form.validation.token_balance_too_low`,
{
token_symbol: token.symbol
}
)
};
// Transaction size may be large when user provides "data"
// so we need to ensure they have sufficient balance to cover worst-case fee
} else if (values.data && balance && balance.lt(ESTIMATED_MAX_TOTAL_FEE)) {
return { return {
amount: i18n.t( amount: i18n.t(
`${packageNS}:tx.form.validation.token_balance_too_low`, `${packageNS}:tx.form.validation.token_balance_too_low`,
...@@ -484,6 +556,12 @@ class TxForm extends Component { ...@@ -484,6 +556,12 @@ class TxForm extends Component {
} }
) )
}; };
// "data" field should be a hex string (not a hash).
// See https://github.com/chainx-org/ChainX/issues/66#issuecomment-496446585
} else if (values.data && !isHexString(values.data)) {
return {
amount: i18n.t(`${packageNS}:tx.form.validation.data_invalid`)
};
} }
return true; return true;
}; };
......
...@@ -95,6 +95,14 @@ class Unlock extends Component { ...@@ -95,6 +95,14 @@ class Unlock extends Component {
defaultValue={`${tx.amount} ${token.symbol}`} defaultValue={`${tx.amount} ${token.symbol}`}
label={i18n.t(`${packageNS}:tx.form.field.amount`)} label={i18n.t(`${packageNS}:tx.form.field.amount`)}
/> />
<FetherForm.Field
as='textarea'
className='form_field_value'
disabled
defaultValue={`${tx.data}`}
label={i18n.t(`${packageNS}:tx.form.field.data`)}
/>
</div>, </div>,
<Form <Form
key='signerForm' key='signerForm'
......
...@@ -139,6 +139,7 @@ ...@@ -139,6 +139,7 @@
"label_unlock": "Konto entsperren:", "label_unlock": "Konto entsperren:",
"field": { "field": {
"to": "Zu", "to": "Zu",
"data": "Daten",
"amount": "Menge", "amount": "Menge",
"tx_speed": "Transaktionsgeschwindigkeit", "tx_speed": "Transaktionsgeschwindigkeit",
"high": "Hoch", "high": "Hoch",
...@@ -160,6 +161,7 @@ ...@@ -160,6 +161,7 @@
}, },
"validation": { "validation": {
"amount_invalid": "Bitte geben Sie einen gültigen Betrag ein", "amount_invalid": "Bitte geben Sie einen gültigen Betrag ein",
"data_invalid": "Bitte geben Sie eine gültige hex Eingabedatenfolge ein",
"eth_balance_too_low_for_gas": "{{chain_id}}-Bilanz zu niedrig, um für Gas zu zahlen.", "eth_balance_too_low_for_gas": "{{chain_id}}-Bilanz zu niedrig, um für Gas zu zahlen.",
"eth_balance_too_low": "Sie haben nicht genug {{chain_id}}-Balance", "eth_balance_too_low": "Sie haben nicht genug {{chain_id}}-Balance",
"fetching_chain_id": "Abrufen der Ketten-ID", "fetching_chain_id": "Abrufen der Ketten-ID",
......
...@@ -139,6 +139,7 @@ ...@@ -139,6 +139,7 @@
"label_unlock": "Unlock account:", "label_unlock": "Unlock account:",
"field": { "field": {
"to": "To", "to": "To",
"data": "Data",
"amount": "Amount", "amount": "Amount",
"tx_speed": "Transaction Speed", "tx_speed": "Transaction Speed",
"high": "High", "high": "High",
...@@ -160,6 +161,7 @@ ...@@ -160,6 +161,7 @@
}, },
"validation": { "validation": {
"amount_invalid": "Please enter a valid amount", "amount_invalid": "Please enter a valid amount",
"data_invalid": "Please enter a valid input data hex string",
"eth_balance_too_low_for_gas": "{{chain_id}} balance too low to pay for gas.", "eth_balance_too_low_for_gas": "{{chain_id}} balance too low to pay for gas.",
"eth_balance_too_low": "You don't have enough {{chain_id}} balance", "eth_balance_too_low": "You don't have enough {{chain_id}} balance",
"fetching_chain_id": "Fetching chain ID", "fetching_chain_id": "Fetching chain ID",
......
...@@ -20,6 +20,7 @@ const baseUrlForChain = chainName => { ...@@ -20,6 +20,7 @@ const baseUrlForChain = chainName => {
chainNameBlockscout = 'mainnet'; chainNameBlockscout = 'mainnet';
baseUrl = `https://blockscout.com/etc/${chainNameBlockscout}`; baseUrl = `https://blockscout.com/etc/${chainNameBlockscout}`;
break; break;
case 'goerli':
case 'kovan': case 'kovan':
case 'ropsten': case 'ropsten':
chainNameBlockscout = chainName; chainNameBlockscout = chainName;
......
...@@ -93,6 +93,7 @@ export const txForErc20 = (tx, token) => { ...@@ -93,6 +93,7 @@ export const txForErc20 = (tx, token) => {
) )
], ],
options: { options: {
data: tx.data,
from: tx.from, from: tx.from,
gasPrice: toWei(tx.gasPrice, 'shannon') // shannon == gwei gasPrice: toWei(tx.gasPrice, 'shannon') // shannon == gwei
} }
...@@ -111,6 +112,7 @@ export const txForErc20 = (tx, token) => { ...@@ -111,6 +112,7 @@ export const txForErc20 = (tx, token) => {
*/ */
export const txForEth = tx => { export const txForEth = tx => {
const output = { const output = {
data: tx.data,
from: tx.from, from: tx.from,
gasPrice: toWei(tx.gasPrice, 'shannon'), // shannon == gwei gasPrice: toWei(tx.gasPrice, 'shannon'), // shannon == gwei
to: tx.to, to: tx.to,
...@@ -128,7 +130,16 @@ export const txForEth = tx => { ...@@ -128,7 +130,16 @@ export const txForEth = tx => {
* EthereumTx object. * EthereumTx object.
*/ */
const getEthereumTx = tx => { const getEthereumTx = tx => {
const { amount, chainId, gas, gasPrice, to, token, transactionCount } = tx; const {
amount,
chainId,
data,
gas,
gasPrice,
to,
token,
transactionCount
} = tx;
const txParams = { const txParams = {
nonce: '0x' + transactionCount.toNumber().toString(16), nonce: '0x' + transactionCount.toNumber().toString(16),
...@@ -138,25 +149,27 @@ const getEthereumTx = tx => { ...@@ -138,25 +149,27 @@ const getEthereumTx = tx => {
}; };
if (isNotErc20TokenAddress(token.address)) { if (isNotErc20TokenAddress(token.address)) {
txParams.data = data;
txParams.to = to; txParams.to = to;
txParams.value = parseFloat(amount) * Math.pow(10, 18); txParams.value = parseFloat(amount) * Math.pow(10, 18);
} else { } else {
txParams.to = token.address; txParams.to = token.address;
txParams.data = txParams.data = txParams.data
'0x' + ? data
new Abi(eip20).functions : '0x' +
.find(f => f._name === 'transfer') new Abi(eip20).functions
.encodeCall([ .find(f => f._name === 'transfer')
new Token('address', to), .encodeCall([
new Token( new Token('address', to),
'uint', new Token(
'0x' + 'uint',
new BigNumber(amount) '0x' +
.multipliedBy(new BigNumber(10).pow(token.decimals)) new BigNumber(amount)
.toNumber() .multipliedBy(new BigNumber(10).pow(token.decimals))
.toString(16) .toNumber()
) .toString(16)
]); )
]);
} }
return new EthereumTx(txParams);