on master help sign in
feat: ethereum transaction UX improvements
commite5702f572f2256e6bf528cf7a997216dea1822d7 authorMatt Condon (shrugs) <mattgcondon@gmail.com> dateTue 13 Aug 2019, 16:53 (7 years ago) parent6738c14
12 files changed+197−83
package-lock.json+13 package.json+1 src/components/CopyButton.js+42 src/components/InlineEthereumTransaction.js+57−21 src/form/Inputs.js+2−3 src/indigo-react/components/LinkButton.js+2−2 src/indigo-react/components/ToggleInput.js+7−1 src/lib/useSuggestedGasPrice.js → src/lib/getSuggestedGasPrice.js+10−17 src/lib/reticket.js+1−1 src/lib/useEthereumTransaction.js+10−34 src/lib/useGasPrice.js+48 src/views/Invite/InviteEmail.js+4−4
package-lock.json+13
@@ -4827,6 +4827,14 @@
48274827 "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz",
48284828 "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40="
48294829 },
4830+ "copy-to-clipboard": {
4831+ "version": "3.2.0",
4832+ "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.2.0.tgz",
4833+ "integrity": "sha512-eOZERzvCmxS8HWzugj4Uxl8OJxa7T2k1Gi0X5qavwydHIfuSHq2dTD09LOg/XyGq4Zpb5IsR/2OJ5lbOegz78w==",
4834+ "requires": {
4835+ "toggle-selection": "^1.0.6"
4836+ }
4837+ },
48304838 "core-js-compat": {
48314839 "version": "3.1.3",
48324840 "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.1.3.tgz",
@@ -17178,6 +17186,11 @@
1717817186 "repeat-string": "^1.6.1"
1717917187 }
1718017188 },
17189+ "toggle-selection": {
17190+ "version": "1.0.6",
17191+ "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
17192+ "integrity": "sha1-bkWxJj8gF/oKzH2J14sVuL932jI="
17193+ },
1718117194 "tough-cookie": {
1718217195 "version": "2.4.3",
1718317196 "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz",
package.json+1
@@ -26,6 +26,7 @@
2626 "bip39": "^2.5.0",
2727 "bn.js": "^4.11.8",
2828 "classnames": "^2.2.6",
29+ "copy-to-clipboard": "^3.2.0",
2930 "ethereum-blockies-base64": "^1.0.2",
3031 "ethereumjs-tx": "^1.3.7",
3132 "file-saver": "^2.0.0",
src/components/CopyButton.jsnew file+42
@@ -0,0 +1,42 @@
1+import React, { useCallback, useEffect, useRef, useState } from 'react';
2+import { LinkButton } from 'indigo-react';
3+import cn from 'classnames';
4+import copy from 'copy-to-clipboard';
5+
6+const COPY_DELAY = 2 * 1000; // ms
7+const renderDefaultCopyText = didCopy => (didCopy ? 'Copied!' : 'Copy');
8+
9+export default function CopyButton({
10+ as: As = LinkButton,
11+ text,
12+ children = renderDefaultCopyText,
13+ className,
14+ ...rest
15+}) {
16+ const [didCopy, setDidCopy] = useState(false);
17+
18+ // what we really want here is a useDebouncedCallback from use-debounced
19+ // but that's a relatively large dep for a single simple usage so ¯\_(ツ)_/¯
20+ const listener = useRef(); // <NodeJS.Listener>
21+ useEffect(() => () => clearTimeout(listener.current), []);
22+
23+ const doCopy = useCallback(() => {
24+ copy(text);
25+ setDidCopy(true);
26+
27+ clearTimeout(listener.current);
28+ listener.current = setTimeout(() => setDidCopy(false), COPY_DELAY);
29+ }, [text]);
30+
31+ return (
32+ <As
33+ onClick={doCopy}
34+ className={cn(className, {
35+ black: !didCopy,
36+ green3: didCopy,
37+ })}
38+ {...rest}>
39+ {children(didCopy)}
40+ </As>
41+ );
42+}
src/components/InlineEthereumTransaction.js+57−21
@@ -20,6 +20,7 @@ import WarningBox from './WarningBox';
2020import { composeValidator, buildCheckboxValidator } from 'form/validators';
2121import BridgeForm from 'form/BridgeForm';
2222import Condition from 'form/Condition';
23+import CopyButton from './CopyButton';
2324
2425export default function InlineEthereumTransaction({
2526 // from useEthereumTransaction.bind
@@ -78,7 +79,7 @@ export default function InlineEthereumTransaction({
7879 [resetGasPrice]
7980 );
8081
81− const renderPrimaryButton = () => {
82+ const renderPrimarySection = () => {
8283 if (error) {
8384 return (
8485 <Grid.Item full as={RestartButton} solid onClick={() => reset()}>
@@ -87,12 +88,9 @@ export default function InlineEthereumTransaction({
8788 );
8889 } else if (completed) {
8990 return (
90− <>
91− <Grid.Divider />
92− <Grid.Item full as={RestartButton} onClick={onReturn}>
93− Return
94− </Grid.Item>
95− </>
91+ <Grid.Item full className="pv4 black f5">
92+ Transaction Complete
93+ </Grid.Item>
9694 );
9795 } else if (showBroadcastButton) {
9896 return (
@@ -121,12 +119,17 @@ export default function InlineEthereumTransaction({
121119 }
122120 };
123121
122+ const serializedTxHex = useMemo(
123+ () => signedTransaction && hexify(signedTransaction.serialize()),
124+ [signedTransaction]
125+ );
126+
124127 return (
125128 <Grid className={cn(className, 'mt1')}>
126129 <BridgeForm validate={validate} onValues={onValues}>
127130 {({ handleSubmit }) => (
128131 <>
129− {renderPrimaryButton()}
132+ {renderPrimarySection()}
130133
131134 {error && (
132135 <Grid.Item full as={ErrorText} className="mt1">
@@ -149,12 +152,14 @@ export default function InlineEthereumTransaction({
149152 full
150153 as={ToggleInput}
151154 name="useAdvanced"
152− label="Advanced Configuration"
153− inverseLabel="Cancel Advanced Configuration"
155+ label="Advanced"
156+ inverseLabel="Back to Defaults"
157+ inverseColor="red3"
154158 disabled={!showConfigureInput || initializing}
155159 />
156160
157161 <Condition when="useAdvanced" is={true}>
162+ <Grid.Divider />
158163 <Grid.Item
159164 full
160165 as={Flex}
@@ -183,13 +188,7 @@ export default function InlineEthereumTransaction({
183188 <Flex.Item as={Text}>Cheap</Flex.Item>
184189 <Flex.Item as={Text}>Fast</Flex.Item>
185190 </Grid.Item>
186− <Grid.Item full as={HelpText} className="mt3">
187− Nonce: {nonce}
188− </Grid.Item>
189− <Grid.Item full as={HelpText} className="mt1">
190− Chain ID: {chainId}
191− </Grid.Item>
192− <Grid.Divider className="mt3" />
191+ <Grid.Divider className="mt4" />
193192 </Condition>
194193 </>
195194 )}
@@ -200,14 +199,42 @@ export default function InlineEthereumTransaction({
200199 full
201200 as={ToggleInput}
202201 name="viewSigned"
203− label="View Signed Transaction"
204− inverseLabel="Hide Signed Transaction"
202+ label="Signed Transaction"
203+ inverseLabel="Hide"
205204 disabled={!showSignedTx}
206205 />
207206 <Condition when="viewSigned" is={true}>
208− <Grid.Item full as="code" className="f6 mono gray4 wrap">
209− {hexify(signedTransaction.serialize())}
207+ <Grid.Divider />
208+ <Grid.Item
209+ full
210+ as={Flex}
211+ justify="between"
212+ className="pv4 black f5">
213+ <Flex.Item>Nonce</Flex.Item>
214+ <Flex.Item>{nonce}</Flex.Item>
215+ </Grid.Item>
216+ <Grid.Divider />
217+ <Grid.Item
218+ full
219+ as={Flex}
220+ justify="between"
221+ className="pv4 black f5">
222+ <Flex.Item>Gas Price</Flex.Item>
223+ <Flex.Item>{gasPrice.toFixed()} Gwei</Flex.Item>
224+ </Grid.Item>
225+ <Grid.Divider />
226+ <Grid.Item
227+ full
228+ as={Flex}
229+ justify="between"
230+ className="mt3 mb2">
231+ <Flex.Item as={H5}>Signed Transaction Hex</Flex.Item>
232+ <Flex.Item as={CopyButton} text={serializedTxHex} />
233+ </Grid.Item>
234+ <Grid.Item full as="code" className="mb4 f6 mono gray4 wrap">
235+ {serializedTxHex}
210236 </Grid.Item>
237+ <Grid.Divider />
211238 </Condition>
212239 </>
213240 )}
@@ -232,6 +259,15 @@ export default function InlineEthereumTransaction({
232259 <Grid.Divider />
233260 </>
234261 )}
262+
263+ {completed && (
264+ <>
265+ <Grid.Item full as={RestartButton} onClick={onReturn}>
266+ Return
267+ </Grid.Item>
268+ <Grid.Divider />
269+ </>
270+ )}
235271 </>
236272 )}
237273 </BridgeForm>
src/form/Inputs.js+2−3
@@ -16,9 +16,8 @@ const PLACEHOLDER_HD_PATH = DEFAULT_HD_PATH;
1616const PLACEHOLDER_MNEMONIC =
1717 'example crew supreme gesture quantum web media hazard theory mercy wing kitten';
1818const PLACEHOLDER_TICKET = '~sampel-ticlyt-migfun-falmel';
19−const PLACEHOLDER_ADDRESS = '0x12345abcdeDB11D175F123F6891AA64F01c24F7d';
20−const PLACEHOLDER_PRIVATE_KEY =
21− '0x12345abcdee6beb2f323fab48b432925c9785808d33a6ca6d7ba00b45e9370c3';
19+const PLACEHOLDER_ADDRESS = '0x';
20+const PLACEHOLDER_PRIVATE_KEY = '0x';
2221const PLACEHOLDER_EMAIL = 'email@example.com';
2322
2423const formatPat = buildFormatter([downcase, ensurePatFormat]);
src/indigo-react/components/LinkButton.js+2−2
@@ -19,9 +19,9 @@ export default function LinkButton({
1919 }),
2020 }}
2121 className={cn(
22− 'pointer underline',
22+ 'us-none pointer underline',
2323 {
24− black: !disabled,
24+ // NOTE: inherit styling from parent otherwise
2525 gray4: disabled,
2626 },
2727 className
src/indigo-react/components/ToggleInput.js+7−1
@@ -10,6 +10,7 @@ export default function ToggleInput({
1010 name,
1111 label,
1212 inverseLabel,
13+ inverseColor = 'black',
1314 className,
1415
1516 //
@@ -52,7 +53,12 @@ export default function ToggleInput({
5253 clickable: !disabled,
5354 })}
5455 htmlFor={name}>
55− <LinkButton disabled={disabled} className="f5">
56+ <LinkButton
57+ disabled={disabled}
58+ className={cn('f5', {
59+ black: !input.checked,
60+ [inverseColor]: input.checked,
61+ })}>
5662 {input.checked ? inverseLabel : label}
5763 </LinkButton>
5864 </Flex.Item>
src/lib/useSuggestedGasPrice.js → src/lib/getSuggestedGasPrice.jsrenamed+10−17
@@ -1,13 +1,13 @@
1−import { useState, useEffect } from 'react';
21import { NETWORK_TYPES } from './network';
32import { DEFAULT_GAS_PRICE_GWEI } from './constants';
43
5−async function getSuggestedGasPrice(networkType) {
4+export default async function getSuggestedGasPrice(networkType) {
65 switch (networkType) {
76 case NETWORK_TYPES.ROPSTEN:
87 return 10;
98 case NETWORK_TYPES.OFFLINE:
109 return DEFAULT_GAS_PRICE_GWEI;
10+ case NETWORK_TYPES.LOCAL:
1111 default:
1212 try {
1313 const response = await fetch(
@@ -17,24 +17,17 @@ async function getSuggestedGasPrice(networkType) {
1717 cache: 'no-cache',
1818 }
1919 );
20+
2021 const json = await response.json();
21− return Math.min(json.fast / 10, 20); // to gwei
22+
23+ // ethgasstation returns values in floating point, one order of magitude
24+ // more than gwei. see: https://docs.ethgasstation.info
25+ const suggestedGasPrice = json.fast / 10; // to gwei
26+
27+ // we don't want to charge users more than our default 20 gwei
28+ return Math.min(suggestedGasPrice, DEFAULT_GAS_PRICE_GWEI);
2229 } catch (e) {
2330 return DEFAULT_GAS_PRICE_GWEI;
2431 }
2532 }
2633}
27−
28−function useSuggestedGasPrice(networkType) {
29− const [gasPrice, _setGasPrice] = useState(DEFAULT_GAS_PRICE_GWEI);
30−
31− useEffect(() => {
32− (async () => {
33− _setGasPrice(await getSuggestedGasPrice(networkType));
34− })();
35− }, [networkType]);
36−
37− return { gasPrice };
38−}
39−
40−export { getSuggestedGasPrice, useSuggestedGasPrice };
src/lib/reticket.js+1−1
@@ -10,7 +10,7 @@ import {
1010} from './keys';
1111import { addHexPrefix } from './wallet';
1212import { sendAndAwaitAll } from './txn';
13−import { getSuggestedGasPrice } from './useSuggestedGasPrice';
13+import getSuggestedGasPrice from './getSuggestedGasPrice';
1414import { GAS_LIMITS } from './constants';
1515import { toWei } from 'web3-utils';
1616
src/lib/useEthereumTransaction.js+10−34
@@ -1,22 +1,22 @@
11import { useCallback, useEffect, useState } from 'react';
22import { Just } from 'folktale/maybe';
3−import { fromWei, toWei } from 'web3-utils';
3+import { toWei } from 'web3-utils';
44
55import { useNetwork } from 'store/network';
66import { useWallet } from 'store/wallet';
77import { usePointCursor } from 'store/pointCursor';
8−import { useSuggestedGasPrice } from 'lib/useSuggestedGasPrice';
98
10−import { GAS_LIMITS } from './constants';
9+import { GAS_LIMITS, DEFAULT_GAS_PRICE_GWEI } from './constants';
1110import {
1211 signTransaction,
1312 sendSignedTransaction,
1413 waitForTransactionConfirm,
1514 hexify,
1615} from './txn';
17−import * as need from './need';
18−import { ensureFundsFor } from './tank';
16+import * as need from 'lib/need';
17+import { ensureFundsFor } from 'lib/tank';
1918import useDeepEqualReference from './useDeepEqualReference';
19+import useGasPrice from './useGasPrice';
2020
2121const STATE = {
2222 NONE: 'NONE',
@@ -37,7 +37,7 @@ export default function useEthereumTransaction(
3737 transactionBuilder,
3838 refetch,
3939 initialGasLimit = GAS_LIMITS.DEFAULT,
40− initialGasPrice = 20
40+ initialGasPrice = DEFAULT_GAS_PRICE_GWEI
4141) {
4242 const { wallet, walletType, walletHdPath } = useWallet();
4343 const { web3, networkType } = useNetwork();
@@ -59,9 +59,7 @@ export default function useEthereumTransaction(
5959 const [state, setState] = useState(STATE.NONE);
6060 const [chainId, setChainId] = useState();
6161 const [nonce, setNonce] = useState();
62− const [gasPrice, setGasPrice] = useState(initialGasPrice); // gwei
63− const [suggestedGasPrice, setSuggestedGasPrice] = useState(gasPrice); // gwei
64− const { gasPrice: estimatedGasPrice } = useSuggestedGasPrice();
62+ const { gasPrice, setGasPrice, resetGasPrice } = useGasPrice(initialGasPrice);
6563 const [gasLimit] = useState(initialGasLimit);
6664 const [unsignedTransaction, setUnsignedTransaction] = useState();
6765 const [signedTransaction, setSignedTransaction] = useState();
@@ -179,25 +177,11 @@ export default function useEthereumTransaction(
179177 setSignedTransaction(undefined);
180178 setNonce(undefined);
181179 setChainId(undefined);
182− setGasPrice(suggestedGasPrice);
180+ resetGasPrice();
183181 setState(STATE.NONE);
184182 setError(undefined);
185183 setNeedFunds(undefined);
186− }, [suggestedGasPrice, setError]);
187−
188− const resetGasPrice = useCallback(() => setGasPrice(suggestedGasPrice), [
189− setGasPrice,
190− suggestedGasPrice,
191− ]);
192−
193− useEffect(() => {
194− const gasPrice = parseInt(
195− fromWei(estimatedGasPrice.toString(), 'gwei'),
196− 10
197− );
198− setSuggestedGasPrice(gasPrice);
199− setGasPrice(gasPrice);
200− }, [estimatedGasPrice]);
184+ }, [resetGasPrice, setError]);
201185
202186 useEffect(() => {
203187 let mounted = true;
@@ -226,15 +210,7 @@ export default function useEthereumTransaction(
226210 })();
227211
228212 return () => (mounted = false);
229− }, [
230− _wallet,
231− _web3,
232− setError,
233− nonce,
234− chainId,
235− networkType,
236− estimatedGasPrice,
237− ]);
213+ }, [_wallet, _web3, setError, nonce, chainId, networkType, initialGasPrice]);
238214
239215 useEffect(() => {
240216 let mounted = true;
src/lib/useGasPrice.jsnew file+48
@@ -0,0 +1,48 @@
1+import { useCallback, useEffect, useState } from 'react';
2+
3+import { useNetwork } from 'store/network';
4+
5+import getSuggestedGasPrice from 'lib/getSuggestedGasPrice';
6+import { DEFAULT_GAS_PRICE_GWEI } from './constants';
7+
8+/**
9+ * handles all of the state of components that manage gas prices
10+ * + simple get/set
11+ * + lazy fetching optimal gas from oracle
12+ * + resetting to oracle (or sync) default
13+ * @param {Number} initialGasPrice the initial gas price available instantly
14+ */
15+export default function useGasPrice(initialGasPrice = DEFAULT_GAS_PRICE_GWEI) {
16+ const { networkType } = useNetwork();
17+ const [suggestedGasPrice, setSuggestedGasPrice] = useState(initialGasPrice); // gwei
18+ const [gasPrice, setGasPrice] = useState(initialGasPrice); // gwei
19+
20+ useEffect(() => {
21+ let mounted = true;
22+
23+ (async () => {
24+ const suggestedGasPrice = await getSuggestedGasPrice(networkType);
25+
26+ if (!mounted) {
27+ return;
28+ }
29+
30+ // TODO: BN
31+ setSuggestedGasPrice(suggestedGasPrice);
32+ setGasPrice(suggestedGasPrice);
33+ })();
34+
35+ return () => (mounted = true);
36+ }, [networkType]);
37+
38+ const resetGasPrice = useCallback(() => setGasPrice(suggestedGasPrice), [
39+ suggestedGasPrice,
40+ ]);
41+
42+ return {
43+ suggestedGasPrice,
44+ gasPrice,
45+ setGasPrice,
46+ resetGasPrice,
47+ };
48+}
src/views/Invite/InviteEmail.js+4−4
@@ -9,7 +9,7 @@ import cn from 'classnames';
99import * as ob from 'urbit-ob';
1010import * as azimuth from 'azimuth-js';
1111import {
12− Grid,
12+Grid,
1313 Flex,
1414 IconButton,
1515 HelpText,
@@ -35,7 +35,6 @@ import {
3535} from 'lib/txn';
3636import * as tank from 'lib/tank';
3737import { MIN_PLANET, GAS_LIMITS } from 'lib/constants';
38−import { useSuggestedGasPrice } from 'lib/useSuggestedGasPrice';
3938import * as need from 'lib/need';
4039import * as wg from 'lib/walletgen';
4140import useSetState from 'lib/useSetState';
@@ -57,6 +56,7 @@ import { FORM_ERROR } from 'final-form';
5756import SubmitButton from 'form/SubmitButton';
5857import FormError from 'form/FormError';
5958import { WARNING, onlyHasWarning } from 'form/helpers';
59+import useGasPrice from 'lib/useGasPrice';
6060
6161const INITIAL_VALUES = { emails: [''] };
6262
@@ -106,7 +106,7 @@ export default function InviteEmail() {
106106 const { pointCursor } = usePointCursor();
107107 const point = need.point(pointCursor);
108108 const { getHasReceived, sendMail } = useMailer();
109− const { gasPrice } = useSuggestedGasPrice(networkType);
109+ const { gasPrice } = useGasPrice();
110110
111111 const cachedEmails = useRef({});
112112
@@ -245,7 +245,7 @@ export default function InviteEmail() {
245245 nonce: nonce + i,
246246 // TODO: ^ make a useTransactionSigner to encapsulate this logic
247247 txn: inviteTx,
248− gasPrice: gasPrice.toString(),
248+ gasPrice: gasPrice.toString(), // expects string gwei
249249 gasLimit: GAS_LIMIT.toString(),
250250 });
251251