1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-24 19:10:22 +01:00

Add MetaMask Swaps (#9482)

This commit is contained in:
Dan J Miller 2020-10-06 15:58:38 -02:30 committed by GitHub
parent 92314cc3ed
commit 30d13422b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
195 changed files with 11058 additions and 370 deletions

1
.storybook/images/0x.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><title>Asset 1</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path d="M24.9,93.22a49.86,49.86,0,0,0,52-1.09A50.18,50.18,0,0,0,88.32,82.07c-2.72-3.79-5.62-7.7-8.68-11.68-.82-1.07-1.67-2.15-2.52-3.24A32.27,32.27,0,0,1,63.55,79.09L55,70.73Z"/><path d="M6.63,25.16A49.87,49.87,0,0,0,7.88,76.89a50.54,50.54,0,0,0,10,11.41c3.79-2.72,7.71-5.62,11.69-8.68l3.24-2.51A32.22,32.22,0,0,1,20.91,63.54l8.42-8.62Z"/><path d="M75.12,6.79a49.87,49.87,0,0,0-52,1.09,50.54,50.54,0,0,0-11.41,10c2.72,3.79,5.62,7.71,8.68,11.69.82,1.07,1.67,2.16,2.51,3.24A32.31,32.31,0,0,1,36.46,20.9h0l8.05,7.85Z"/><path d="M93.48,74.63a49.82,49.82,0,0,0-1.35-51.5A50.18,50.18,0,0,0,82.07,11.68c-3.79,2.72-7.7,5.62-11.68,8.68-1.07.82-2.15,1.67-3.24,2.52a32.27,32.27,0,0,1,12,13.6l0,.12L71.05,45Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 872 B

BIN
.storybook/images/AST.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140.85 136.33"><title>BAT_icon</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><polygon points="106.86 136.33 140.85 135.33 64.19 0 3.52 109.33 106.86 136.33" opacity="0.4"/><polygon points="127.05 110.66 63.78 74.22 0.72 110.66 127.05 110.66" fill="#662d91"/><polygon points="63.99 0.5 63.97 73.51 127.33 109.79 63.99 0.5" fill="#9e1f63"/><polygon points="0 109.94 63.45 73.81 63.61 0.8 0 109.94" fill="#ff5000"/><polygon points="63.84 44.79 38.4 88.48 89.86 88.48 63.84 44.79" fill="#fff" stroke="#ff5000" stroke-miterlimit="10" stroke-width="0.83"/></g></g></svg>

After

Width:  |  Height:  |  Size: 652 B

View File

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 91 91"><defs><style>.cls-1{fill:#231f20;stroke:#fff;stroke-miterlimit:10;}.cls-2{fill:#fff;}</style></defs><title>CVL_token</title><circle class="cls-1" cx="45.5" cy="45.5" r="45"/><g id="CIVIL_LOGO" data-name="CIVIL LOGO"><path class="cls-2" d="M29.2,45A17.57,17.57,0,0,1,47.05,27.06c6.48,0,11,2.54,14,6.76l-4.23,3c-2.35-3.1-5.26-4.79-10-4.79-7,0-12,5.54-12,13,0,7.61,5.16,13,12.21,13a12.5,12.5,0,0,0,10.52-5.07L61.8,55.8c-3.57,4.79-8.17,7.14-14.94,7.14A17.47,17.47,0,0,1,29.2,45Z"/></g></svg>

After

Width:  |  Height:  |  Size: 580 B

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 328 328" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<rect id="Page-5" serif:id="Page 5" x="-519" y="-228" width="1366" height="768" style="fill:none;"/>
<g id="Layer-1" serif:id="Layer 1">
<path d="M264.02,56.858c-31.022,-11.562 -64.566,-17.858 -99.594,-17.858c-35.029,0 -68.574,6.296 -99.595,17.858c-14.367,5.324 -28.162,11.733 -41.325,19.232c1.945,15.226 5.037,30.107 9.274,44.531c12.247,-7.899 25.299,-14.768 38.921,-20.377c28.561,-11.79 59.87,-18.316 92.725,-18.316c32.853,0 64.164,6.526 92.724,18.316c13.622,5.609 26.673,12.478 38.923,20.377c4.233,-14.424 7.326,-29.305 9.271,-44.531c-13.164,-7.499 -26.959,-13.908 -41.325,-19.232" style="fill:#0a2052;fill-rule:nonzero;"/>
<path d="M246.331,132.984l-0.056,0c-17.172,41.841 -45.734,77.901 -81.849,104.172c-36.117,-26.271 -64.68,-62.331 -81.852,-104.172l-0.055,0c-3.665,1.316 -7.27,2.69 -10.819,4.178c-9.731,4.007 -19.116,8.643 -28.161,13.853c17.915,42.813 46.02,80.362 81.221,109.609c12.364,10.303 25.642,19.575 39.666,27.647c14.022,-8.072 27.3,-17.344 39.664,-27.647c35.201,-29.247 63.305,-66.796 81.22,-109.609c-9.042,-5.21 -18.429,-9.846 -28.161,-13.853c-3.548,-1.488 -7.154,-2.862 -10.818,-4.178" style="fill:#31c8c4;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="Layer_1"
x="0px"
y="0px"
width="1055px"
height="680px"
viewBox="0 0 1055 680"
enable-background="new 0 0 1055 680"
xml:space="preserve"
inkscape:version="0.91 r13725"
sodipodi:docname="gnosis.svg"><metadata
id="metadata9"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
id="defs7" /><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="833"
inkscape:window-height="707"
id="namedview5"
showgrid="false"
inkscape:zoom="0.29383886"
inkscape:cx="527.5"
inkscape:cy="340"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="Layer_1" /><path
d="m 180.15745,575.08269 0,69.24629 c -18.04707,14.49955 -44.71746,24.43308 -69.10742,24.43308 -52.586837,0 -93.485772,-41.60718 -93.485772,-95.29343 0,-53.67766 41.415893,-94.75214 95.266662,-94.75214 25.40529,0 50.81059,10.20234 68.34766,26.31595 l -17.02592,23.3419 c -13.71275,-13.41573 -32.77515,-21.46517 -51.32174,-21.46517 -36.326471,0 -64.784723,29.25194 -64.784723,66.56069 0,37.57878 28.712665,66.84422 65.039133,66.84422 13.21438,0 28.20268,-4.83236 40.91171,-12.88916 l 0,-52.34223 26.16041,0 z m 178.59333,92.34271 -94.76364,-134.75262 0,134.75262 -29.96615,0 0,-187.90617 29.46777,0 95.26086,135.01652 0,-135.01652 29.47938,0 0,187.90617 -29.47822,0 z m 261.66675,-93.95677 c 0,53.68503 -41.14754,95.29343 -94.24552,95.29343 -53.09799,0 -94.25134,-41.60718 -94.25134,-95.29343 0,-53.94769 41.15451,-94.75214 94.25134,-94.75214 53.09798,0 94.24552,41.07448 94.24552,94.75214 z m -158.01492,0 c 0,37.32102 29.21801,66.84423 64.02381,66.84423 34.80696,0 63.2594,-29.52321 63.2594,-66.84423 0,-37.30874 -28.45244,-66.29679 -63.2594,-66.29679 -35.06138,0 -64.02381,28.98927 -64.02381,66.29679 z m 322.14853,-75.95293 -12.4616,27.90792 c -19.29938,-12.34788 -38.6127,-17.44905 -52.07452,-17.44905 -17.52546,0 -28.95198,6.98281 -28.95198,19.59581 0,41.06712 96.02177,19.06188 95.76852,86.7027 0,33.54915 -27.94246,54.21772 -67.06515,54.21772 -27.95291,0 -54.36191,-12.0717 -72.65874,-29.79078 l 12.95067,-27.38257 c 18.29218,17.7203 41.16845,27.38257 60.21109,27.38257 20.8398,0 33.2921,-8.31947 33.2921,-22.82024 0,-41.86985 -96.05197,-18.51077 -96.05197,-85.35377 0,-32.20634 26.17202,-52.34222 64.80331,-52.34222 23.10744,0 45.72233,7.77941 62.23827,19.33191 z m 48.7776,169.9097 0,-187.90617 29.97312,0 0,187.90617 -29.97312,0 z m 208.82086,-169.9097 -12.4616,27.90792 c -19.2994,-12.34788 -38.60573,-17.44905 -52.06755,-17.44905 -17.53243,0 -28.95197,6.98281 -28.95197,19.59581 0,41.06712 96.02172,19.06188 95.76622,86.7027 0,33.54915 -27.9471,54.21772 -67.06982,54.21772 -27.9471,0 -54.35495,-12.0717 -72.65875,-29.79078 l 12.95764,-27.38257 c 18.28522,17.7203 41.16845,27.38257 60.20645,27.38257 20.83168,0 33.29208,-8.31947 33.29208,-22.82024 0,-41.86985 -96.04033,-18.51077 -96.04033,-85.35377 0,-32.20634 26.16504,-52.34222 64.78936,-52.34222 23.11907,0 45.72927,7.77941 62.23827,19.33191 z M 673.44697,99.277115 660.77511,112.67812 c 10.21021,16.72364 13.20276,38.09921 6.34871,58.27927 -11.70533,34.44885 -47.62288,52.36799 -80.21679,40.00662 -4.60383,-1.73435 -8.89633,-3.99649 -12.84263,-6.67473 l -40.83272,43.13042 -35.95589,-37.98997 c -16.42416,11.61019 -37.64616,15.16358 -57.67045,7.56829 -33.54186,-12.70874 -50.96277,-51.72606 -38.94727,-87.14457 1.83665,-5.40312 4.26229,-10.38891 7.15145,-14.95124 l -15.32287,-16.189709 -2.96118,5.113439 c -16.0652,27.7103 -24.58978,59.55702 -24.66297,92.10582 -0.16961,97.82929 75.00655,177.56145 167.592,177.74679 l 0.3276,0 c 92.39841,0 167.72095,-79.43635 167.8952,-177.05943 0.0616,-32.55371 -8.34335,-64.44707 -24.27379,-92.21506 l -2.95654,-5.126945 z M 426.7813,134.95462 c -4.42725,6.07083 -7.08988,13.6649 -7.08988,21.92054 0,19.89039 15.25085,36.004 34.07625,36.004 7.81943,0 14.99527,-2.81448 20.74686,-7.49219 L 426.7813,134.95462 Z m 167.96258,47.47794 c 5.44955,3.88725 11.9911,6.14816 19.06355,6.14816 18.80682,0 34.07045,-16.11974 34.07045,-36.00399 0,-7.45905 -2.14683,-14.3854 -5.82014,-20.13465 l -47.31386,49.99048 z m -61.24501,38.08571 -138.568,-146.95321 5.3996,-6.059787 C 434.79008,28.82795 481.41738,7.5309355 531.60762,7.5309355 l 0.34038,0 C 582.66797,7.634039 631.27132,30.486199 665.33015,70.236289 l 5.20443,6.059787 L 533.49887,220.51827 Z M 417.69097,73.366218 533.54185,196.22757 647.94642,75.827201 C 617.32972,43.343454 575.48051,24.802003 531.91198,24.711174 l -0.30436,0 c -43.03299,0 -83.17217,17.206014 -113.91665,48.655044 z M 380.71975,315.13539 364.40479,314.00493 C 295.43096,309.26954 200.28512,291.3197 138.71716,232.71762 100.99781,196.82287 93.780142,162.70788 93.494363,161.26811 l -2.080611,-10.32508 251.008118,0 -1.24651,9.69541 c -1.14311,8.96264 -1.72745,18.02102 -1.75068,26.9137 -0.0674,40.43622 11.07916,79.57628 32.23959,113.19785 l 9.05548,14.3854 z m 304.05615,0 9.05548,-14.3854 c 21.04426,-33.40922 32.21404,-72.28539 32.28258,-112.41352 0.0116,-9.22654 -0.57156,-18.54145 -1.72629,-27.6968 l -1.2407,-9.69542 250.97792,0 -2.07364,10.32509 c -0.28578,1.43976 -7.51042,35.55476 -45.23441,71.44951 -61.56797,58.60085 -156.73937,76.55069 -225.72481,81.28731 l -16.31613,1.12923 z"
id="path3"
inkscape:connector-curvature="0"
style="fill:#00a6c4" /></svg>

After

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

BIN
.storybook/images/omg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

29
.storybook/images/sai.svg Normal file
View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="105px" height="105px" viewBox="0 0 105 105" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 49.3 (51167) - http://www.bohemiancoding.com/sketch -->
<title>icon</title>
<desc>Created with Sketch.</desc>
<defs>
<path d="M68.1775968,52.2619297 L53.250349,37.3346818 L38.3184692,52.2665616 L15.9241234,52.2700356 L53.2538229,14.9403361 L90.5719426,52.2584557 L68.1775968,52.2619297 Z" id="path-1"></path>
<filter x="-5.4%" y="-5.4%" width="110.7%" height="121.4%" filterUnits="objectBoundingBox" id="filter-2">
<feOffset dx="0" dy="2" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
<feGaussianBlur stdDeviation="1" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0" type="matrix" in="shadowBlurOuter1"></feColorMatrix>
</filter>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="DAI_small" transform="translate(-37.000000, -37.000000)">
<g id="ICON_DAI" transform="translate(10.800000, 10.800000)">
<g id="icon" transform="translate(25.730000, 26.730000)">
<rect id="Rectangle" fill="#FFCC80" transform="translate(53.248033, 52.264246) rotate(-315.000000) translate(-53.248033, -52.264246) " x="16.3049794" y="15.3097285" width="73.8861073" height="73.9090343"></rect>
<polygon id="Rectangle-Copy" fill="#FFB74D" transform="translate(53.248033, 52.264246) rotate(-315.000000) translate(-53.248033, -52.264246) " points="16.3049794 15.3097285 90.1910867 15.3097285 66.3093749 65.3296405 16.3049794 89.2187628"></polygon>
<g id="Combined-Shape">
<use fill="black" fill-opacity="1" filter="url(#filter-2)" xlink:href="#path-1"></use>
<use fill="#FCFCFC" fill-rule="evenodd" xlink:href="#path-1"></use>
</g>
<polygon id="Rectangle-Copy-4" fill-opacity="0.0299999993" fill="#000000" transform="translate(53.248033, 52.264246) rotate(-315.000000) translate(-53.248033, -52.264246) " points="16.3049794 15.3097285 90.1910867 15.3097285 90.1910867 89.2187628"></polygon>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 913 B

BIN
.storybook/images/wbtc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
.storybook/images/wed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

View File

@ -114,6 +114,10 @@
"amount": {
"message": "Amount"
},
"amountInEth": {
"message": "$1 ETH",
"description": "Displays an eth amount to the user. $1 is a decimal number"
},
"amountWithColon": {
"message": "Amount:"
},
@ -125,6 +129,9 @@
"message": "MetaMask",
"description": "The name of the application"
},
"approvalTxGasCost": {
"message": "Approval Tx Gas Cost"
},
"approve": {
"message": "Approve spend limit"
},
@ -639,6 +646,10 @@
"externalExtension": {
"message": "External Extension"
},
"extraApprovalGas": {
"message": "+$1 approval gas",
"description": "Expresses an additional gas amount the user will have to pay, on top of some other displayed amount. $1 is a decimal amount of gas"
},
"failed": {
"message": "Failed"
},
@ -943,6 +954,9 @@
"metamaskDescription": {
"message": "Connecting you to Ethereum and the Decentralized Web."
},
"metamaskSwapsOfflineDescription": {
"message": "MetaMask Swaps is undergoing maintenance. Please check back later."
},
"metamaskVersion": {
"message": "MetaMask Version"
},
@ -1069,6 +1083,9 @@
"off": {
"message": "Off"
},
"offlineForMaintenance": {
"message": "Offline for maintenance"
},
"ok": {
"message": "Ok"
},
@ -1082,6 +1099,9 @@
"onlyAddTrustedNetworks": {
"message": "A malicious Ethereum network provider can lie about the state of the blockchain and record your network activity. Only add custom networks you trust."
},
"onlyAvailableOnMainnet": {
"message": "Only available on mainnet"
},
"onlyConnectTrust": {
"message": "Only connect with sites you trust."
},
@ -1471,6 +1491,9 @@
"speedUpTransaction": {
"message": "Speed up this transaction"
},
"spendLimitInsufficient": {
"message": "Spend limit insufficient"
},
"spendLimitInvalid": {
"message": "Spend limit invalid; must be a positive number"
},
@ -1532,6 +1555,265 @@
"supportCenter": {
"message": "Visit our Support Center"
},
"swap": {
"message": "Swap"
},
"swapAdvancedSlippageInfo": {
"message": "If the price changes between the time your order is placed and confirmed its called “slippage”. Your swap will automatically cancel if slippage exceeds your “max slippage” setting."
},
"swapAggregator": {
"message": "Aggregator"
},
"swapAmountReceived": {
"message": "Guaranteed amount"
},
"swapAmountReceivedInfo": {
"message": "This is the minimum amount you will receive. You may receive more depending on slippage."
},
"swapApproval": {
"message": "Approve $1 for swaps",
"description": "Used in the transaction display list to describe a transaction that is an approve call on a token that is to be swapped.. $1 is the symbol of a token that has been approved."
},
"swapApproveNeedMoreTokens": {
"message": "You need $1 more $2 to complete this swap",
"description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol."
},
"swapBuildQuotePlaceHolderText": {
"message": "No tokens available matching $1",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
},
"swapCheckingQuote": {
"message": "Checking $1",
"description": "Shown to the user during quote loading. $1 is the name of an aggregator. The message indicates that metamask is currently checking if that aggregator has a trade/quote for their requested swap."
},
"swapCustom": {
"message": "custom"
},
"swapDecentralizedExchange": {
"message": "Decentralized exchange"
},
"swapEditLimit": {
"message": "Edit limit"
},
"swapEnableDescription": {
"message": "This is required and gives MetaMask permission to swap your $1.",
"description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps."
},
"swapEstimatedNetworkFee": {
"message": "Estimated network fee"
},
"swapEstimatedNetworkFees": {
"message": "Estimated network fees"
},
"swapEstimatedNetworkFeesInfo": {
"message": "This is an estimate of the network fee that will be used to complete your swap. The actual amount may change according to network conditions."
},
"swapEstimatedTime": {
"message": "Estimated time:"
},
"swapEstimatedTimeCalculating": {
"message": "Calculating..."
},
"swapEstimatedTimeFull": {
"message": "$1 $2",
"description": "This message shows bolded swapEstimatedTime message, which is substited for $1, followed by either the estimated remaining transaction time in mm:ss, or the swapEstimatedTimeCalculating message, which are substituted for $2."
},
"swapFailedErrorDescription": {
"message": "Your funds are safe and still available in your wallet."
},
"swapFailedErrorTitle": {
"message": "Swap failed"
},
"swapFetchingQuotesErrorDescription": {
"message": "Hmmm... something went wrong. Try again, or if errors persist, contact customer support."
},
"swapFetchingQuotesErrorTitle": {
"message": "Error fetching quotes"
},
"swapFetchingTokens": {
"message": "Fetching tokens..."
},
"swapFinalizing": {
"message": "Finalizing..."
},
"swapGetQuotes": {
"message": "Get quotes"
},
"swapHighSlippageWarning": {
"message": "Slippage amount is very high. Make sure you know what you are doing!"
},
"swapIntroLearnMoreHeader": {
"message": "Want to learn more?"
},
"swapIntroLearnMoreLink": {
"message": "Learn more about MetaMask Swaps"
},
"swapIntroLiquiditySourcesLabel": {
"message": "Liquidity sources include:"
},
"swapIntroPopupSubTitle": {
"message": "You can now swap tokens directly in your MetaMask wallet. MetaMask Swaps agglomerates multiple decentralized exchange aggregators, professional market makers, and individual DEXs to ensure MetaMask users always get the best price with the lowest network fees."
},
"swapIntroPopupTitle": {
"message": "Token swapping is here!"
},
"swapLearnMoreContractsAuditReview": {
"message": "Review our official contracts audit"
},
"swapLowSlippageError": {
"message": "Transaction may fail, max slippage too low."
},
"swapMaxNetworkFeeInfo": {
"message": "The Max network fee is the most youll pay to complete your transaction. The max fee helps ensure your Swap has the best chance of succeeding. MetaMask does not profit from network fees."
},
"swapMaxNetworkFees": {
"message": "Max network fee"
},
"swapMaxSlippage": {
"message": "Max slippage"
},
"swapMetaMaskFee": {
"message": "MetaMask fee"
},
"swapMetaMaskFeeDescription": {
"message": "A service fee of $1% is automatically factored into each quote, which supports ongoing development to make MetaMask even better.",
"description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number."
},
"swapMinimumAmountReceivingInfoTooltip": {
"message": "This is the minimum amount you will receive. You may receive more depending on slippage."
},
"swapNQuotesAvailable": {
"message": "$1 quotes available",
"description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen"
},
"swapNewQuoteIn": {
"message": "New quotes in $1",
"description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00"
},
"swapOnceTransactionHasProcess": {
"message": "Your $1 will be added to your account once this transaction has processed.",
"description": "This message communicates the token that is being transferred. It is shown on the awaiting swap screen. The $1 will be a token symbol."
},
"swapProcessing": {
"message": "Processing"
},
"swapQuoteDetails": {
"message": "Quote details"
},
"swapQuoteDetailsSlippageInfo": {
"message": "If the price changes between the time your order is placed and confirmed its called \"slippage\". Your Swap will automatically cancel if slippage exceeds your \"max slippage\" setting."
},
"swapQuoteNofN": {
"message": "Quote $1 of $2",
"description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load."
},
"swapQuoteSource": {
"message": "Quote source"
},
"swapQuotesAreRefreshed": {
"message": "Quotes are refreshed often to reflect current market conditions."
},
"swapQuotesExpiredErrorDescription": {
"message": "Please request new quotes to get the latest rates."
},
"swapQuotesExpiredErrorTitle": {
"message": "Quotes timeout"
},
"swapQuotesNotAvailableErrorDescription": {
"message": "Try adjusting the amount or slippage settings and try again."
},
"swapQuotesNotAvailableErrorTitle": {
"message": "No quotes available"
},
"swapRate": {
"message": "Rate"
},
"swapReceiving": {
"message": "Receiving"
},
"swapRequestForQuotation": {
"message": "Request for quotation"
},
"swapSearchForAToken": {
"message": "Search for a token"
},
"swapSelect": {
"message": "Select"
},
"swapSelectAQuote": {
"message": "Select a quote"
},
"swapSelectQuotePopoverDescription": {
"message": "Below are all the quotes gathered from multiple liquidity sources."
},
"swapSlippageTooLow": {
"message": "Slippage must be greater than zero"
},
"swapSource": {
"message": "Liquidity source"
},
"swapSourceInfo": {
"message": "We search multiple liquidity sources (exchanges, aggregators and professional market makers) to find the best rates and lowest network fees."
},
"swapStartSwapping": {
"message": "Start swapping"
},
"swapSwapFrom": {
"message": "Swap from"
},
"swapSwapTo": {
"message": "Swap to"
},
"swapThisWillAllowApprove": {
"message": "This will allow $1 to be swapped."
},
"swapTokenAvailable": {
"message": "Your $1 has been added to your account.",
"description": "This message is shown after a swap is successful and communicates the exact amount of tokens the user has received for a swap. The $1 is a decimal number of tokens followed by the token symbol."
},
"swapTokenToToken": {
"message": "Swap $1 to $2",
"description": "Used in the transaction display list to describe a swap. $1 and $2 are the symbols of tokens in involved in a swap."
},
"swapTransactionComplete": {
"message": "Transaction complete"
},
"swapUnknown": {
"message": "Unknown"
},
"swapViewToken": {
"message": "View $1"
},
"swapYourTokenBalance": {
"message": "$1 $2 available to swap",
"description": "Tells the user how much of a token they have in their balance. $1 is a decimal number amount of tokens, and $2 is a token symbol"
},
"swapZeroSlippage": {
"message": "0% Slippage"
},
"swapsAdvancedOptions": {
"message": "Advanced Options"
},
"swapsAlmostDone": {
"message": "Almost done..."
},
"swapsBestQuote": {
"message": "Best quote"
},
"swapsConvertToAbout": {
"message": "Convert $1 to about",
"description": "This message is part of a quote for a swap. The $1 is the amount being converted, and the amount it is being swapped for is below this message"
},
"swapsMaxSlippage": {
"message": "Max slippage"
},
"swapsNotEnoughForTx": {
"message": "Not enough $1 to complete this transaction",
"description": "Tells the user that they don't have enough of a token for a proposed swap. $1 is a token symbol"
},
"swapsViewInActivity": {
"message": "View in activity"
},
"switchNetworks": {
"message": "Switch Networks"
},
@ -1577,6 +1859,9 @@
"terms": {
"message": "Terms of Use"
},
"termsOfService": {
"message": "Terms of Service"
},
"testFaucet": {
"message": "Test Faucet"
},

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -0,0 +1,3 @@
<svg width="12" height="16" viewBox="0 0 12 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.35075 15.9844C3.77098 15.9844 4.07114 15.6722 4.07114 15.2351L4.07114 3.9961L5.51193 3.9961C6.1723 3.9961 6.47246 3.21561 6.02221 2.74732L3.62089 0.249757C3.35074 -0.0624391 2.9005 -0.0624391 2.63035 0.249757L0.229027 2.74732C-0.251237 3.21561 0.0789448 3.9961 0.709292 3.9961L2.15008 3.9961L2.15008 15.2351C2.15008 15.6722 2.48027 15.9844 2.87048 15.9844L3.35075 15.9844ZM7.91325 0.749269L7.91325 11.9883L6.47246 11.9883C5.84212 11.9883 5.51193 12.8 5.9922 13.2683L8.39352 15.7659C8.66367 16.078 9.11392 16.078 9.38406 15.7659L11.7854 13.2683C12.2356 12.8 11.9355 11.9883 11.2751 11.9883L9.83431 11.9883L9.83431 0.749269C9.83431 0.343415 9.53415 5.36924e-07 9.11391 5.55292e-07L8.63365 5.76285e-07C8.24344 5.93342e-07 7.91325 0.343415 7.91325 0.749269Z" fill="#D6D9DC"/>
</svg>

After

Width:  |  Height:  |  Size: 887 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 118 KiB

View File

@ -239,6 +239,7 @@ function setupController (initState, initLangCode) {
initLangCode,
// platform specific api
platform,
extension,
getRequestAccountTabIds: () => {
return requestAccountTabIds
},

View File

@ -22,6 +22,7 @@ export default class AppStateController extends EventEmitter {
this.store = new ObservableStore({
timeoutMinutes: 0,
connectedStatusPopoverHasBeenShown: true,
swapsWelcomeMessageHasBeenShown: false,
defaultHomeActiveTabName: null, ...initState,
})
this.timer = null
@ -110,6 +111,15 @@ export default class AppStateController extends EventEmitter {
})
}
/**
* Record that the user has seen the swap screen welcome message
*/
setSwapsWelcomeMessageHasBeenShown () {
this.store.updateState({
swapsWelcomeMessageHasBeenShown: true,
})
}
/**
* Sets the last active time to the current time
* @returns {void}

View File

@ -0,0 +1,620 @@
import { ethers } from 'ethers'
import log from 'loglevel'
import BigNumber from 'bignumber.js'
import ObservableStore from 'obs-store'
import { mapValues } from 'lodash'
import abi from 'human-standard-token-abi'
import { calcTokenAmount } from '../../../ui/app/helpers/utils/token-util'
import { calcGasTotal } from '../../../ui/app/pages/send/send.utils'
import { conversionUtil } from '../../../ui/app/helpers/utils/conversion-util'
import {
ETH_SWAPS_TOKEN_ADDRESS,
DEFAULT_ERC20_APPROVE_GAS,
QUOTES_EXPIRED_ERROR,
QUOTES_NOT_AVAILABLE_ERROR,
} from '../../../ui/app/helpers/constants/swaps'
import {
fetchTradesInfo as defaultFetchTradesInfo,
fetchSwapsFeatureLiveness as defaultFetchSwapsFeatureLiveness,
} from '../../../ui/app/pages/swaps/swaps.util'
const METASWAP_ADDRESS = '0x016B4bf68d421147c06f1b8680602c5bf0Df91A8'
// The MAX_GAS_LIMIT is a number that is higher than the maximum gas costs we have observed on any aggregator
const MAX_GAS_LIMIT = 2500000
// To ensure that our serves are not spammed if MetaMask is left idle, we limit the number of fetches for quotes that are made on timed intervals.
// 3 seems to be an appropriate balance of giving users the time they need when MetaMask is not left idle, and turning polling off when it is.
const POLL_COUNT_LIMIT = 3
function calculateGasEstimateWithRefund (maxGas = MAX_GAS_LIMIT, estimatedRefund = 0, estimatedGas = 0) {
const maxGasMinusRefund = new BigNumber(
maxGas,
10,
)
.minus(estimatedRefund, 10)
const gasEstimateWithRefund = maxGasMinusRefund.lt(
estimatedGas,
16,
)
? maxGasMinusRefund.toString(16)
: estimatedGas
return gasEstimateWithRefund
}
// This is the amount of time to wait, after successfully fetching quotes and their gas estimates, before fetching for new quotes
const QUOTE_POLLING_INTERVAL = 50 * 1000
const initialState = {
swapsState: {
quotes: {},
fetchParams: null,
tokens: null,
tradeTxId: null,
approveTxId: null,
maxMode: false,
quotesLastFetched: null,
customMaxGas: '',
customGasPrice: null,
selectedAggId: null,
customApproveTxData: '',
errorKey: '',
topAggId: null,
routeState: '',
swapsFeatureIsLive: false,
},
}
export default class SwapsController {
constructor ({
getBufferedGasLimit,
provider,
getProviderConfig,
tokenRatesStore,
fetchTradesInfo = defaultFetchTradesInfo,
fetchSwapsFeatureLiveness = defaultFetchSwapsFeatureLiveness,
}) {
this.store = new ObservableStore({
swapsState: { ...initialState.swapsState },
})
this._fetchTradesInfo = fetchTradesInfo
this._fetchSwapsFeatureLiveness = fetchSwapsFeatureLiveness
this.getBufferedGasLimit = getBufferedGasLimit
this.tokenRatesStore = tokenRatesStore
this.pollCount = 0
this.getProviderConfig = getProviderConfig
this.ethersProvider = new ethers.providers.Web3Provider(provider)
this._setupSwapsLivenessFetching()
}
// Once quotes are fetched, we poll for new ones to keep the quotes up to date. Market and aggregator contract conditions can change fast enough
// that quotes will no longer be available after 1 or 2 minutes. When fetchAndSetQuotes is first called it, receives fetch that parameters are stored in
// state. These stored parameters are used on subsequent calls made during polling.
// Note: we stop polling after 3 requests, until new quotes are explicitly asked for. The logic that enforces that maximum is in the body of fetchAndSetQuotes
pollForNewQuotes () {
this.pollingTimeout = setTimeout(() => {
const { swapsState } = this.store.getState()
this.fetchAndSetQuotes(swapsState.fetchParams, swapsState.fetchParams.metaData, true)
}, QUOTE_POLLING_INTERVAL)
}
stopPollingForQuotes () {
clearTimeout(this.pollingTimeout)
}
async fetchAndSetQuotes (fetchParams, fetchParamsMetaData = {}, isPolledRequest) {
if (!fetchParams) {
return null
}
// Every time we get a new request that is not from the polling, we reset the poll count so we can poll for up to three more sets of quotes with these new params.
if (!isPolledRequest) {
this.pollCount = 0
}
// If there are any pending poll requests, clear them so that they don't get call while this new fetch is in process
clearTimeout(this.pollingTimeout)
if (!isPolledRequest) {
this.setSwapsErrorKey('')
}
let newQuotes = await this._fetchTradesInfo(fetchParams)
newQuotes = mapValues(newQuotes, (quote) => ({
...quote,
sourceTokenInfo: fetchParamsMetaData.sourceTokenInfo,
destinationTokenInfo: fetchParamsMetaData.destinationTokenInfo,
}))
const quotesLastFetched = Date.now()
let approvalRequired = false
if (fetchParams.sourceToken !== ETH_SWAPS_TOKEN_ADDRESS && Object.values(newQuotes).length) {
const allowance = await this._getERC20Allowance(
fetchParams.sourceToken,
fetchParams.fromAddress,
)
// For a user to be able to swap a token, they need to have approved the MetaSwap contract to withdraw that token.
// _getERC20Allowance() returns the amount of the token they have approved for withdrawal. If that amount is greater
// than 0, it means that approval has already occured and is not needed. Otherwise, for tokens to be swapped, a new
// call of the ERC-20 approve method is required.
approvalRequired = allowance.eq(0)
if (!approvalRequired) {
newQuotes = mapValues(newQuotes, (quote) => ({
...quote,
approvalNeeded: null,
}))
} else if (!isPolledRequest) {
const { gasLimit: approvalGas } = await this.timedoutGasReturn({
...Object.values(newQuotes)[0].approvalNeeded,
gas: DEFAULT_ERC20_APPROVE_GAS,
})
newQuotes = mapValues(newQuotes, (quote) => ({
...quote,
approvalNeeded: {
...quote.approvalNeeded,
gas: approvalGas || DEFAULT_ERC20_APPROVE_GAS,
},
}))
}
}
const topAggIdData = await this._findTopQuoteAggId(newQuotes)
let { topAggId } = topAggIdData
const { isBest } = topAggIdData
if (isBest) {
newQuotes[topAggId].isBestQuote = true
}
// We can reduce time on the loading screen by only doing this after the
// loading screen and best quote have rendered.
if (!approvalRequired && !fetchParams?.balanceError) {
newQuotes = await this.getAllQuotesWithGasEstimates(newQuotes)
if (fetchParamsMetaData.maxMode && fetchParams.sourceToken === ETH_SWAPS_TOKEN_ADDRESS) {
newQuotes = await this._modifyValuesForMaxEthMode(newQuotes, fetchParamsMetaData.accountBalance)
}
if (Object.values(newQuotes).length === 0) {
this.setSwapsErrorKey(QUOTES_NOT_AVAILABLE_ERROR)
} else {
const {
topAggId: topAggIdAfterGasEstimates,
isBest: isBestAfterGasEstimates,
} = await this._findTopQuoteAggId(newQuotes)
topAggId = topAggIdAfterGasEstimates
if (isBestAfterGasEstimates) {
newQuotes = mapValues(newQuotes, (quote) => ({ ...quote, isBestQuote: false }))
newQuotes[topAggId].isBestQuote = true
}
}
}
const { swapsState } = this.store.getState()
let { selectedAggId } = swapsState
if (!newQuotes[selectedAggId]) {
selectedAggId = null
}
this.store.updateState({
swapsState: {
...swapsState,
quotes: newQuotes,
fetchParams: { ...fetchParams, metaData: fetchParamsMetaData },
quotesLastFetched,
selectedAggId,
topAggId,
},
})
// We only want to do up to a maximum of three requests from polling.
this.pollCount += 1
if (this.pollCount < POLL_COUNT_LIMIT + 1) {
this.pollForNewQuotes()
} else {
this.resetPostFetchState()
this.setSwapsErrorKey(QUOTES_EXPIRED_ERROR)
return null
}
return [newQuotes, topAggId]
}
safeRefetchQuotes () {
const { swapsState } = this.store.getState()
if (!this.pollingTimeout && swapsState.fetchParams) {
this.fetchAndSetQuotes(swapsState.fetchParams)
}
}
setSelectedQuoteAggId (selectedAggId) {
const { swapsState } = this.store.getState()
this.store.updateState({ swapsState: { ...swapsState, selectedAggId } })
}
setSwapsTokens (tokens) {
const { swapsState } = this.store.getState()
this.store.updateState({ swapsState: { ...swapsState, tokens } })
}
setSwapsErrorKey (errorKey) {
const { swapsState } = this.store.getState()
this.store.updateState({ swapsState: { ...swapsState, errorKey } })
}
async getAllQuotesWithGasEstimates (quotes) {
const quoteGasData = await Promise.all(
Object.values(quotes).map(async (quote) => {
const { gasLimit, simulationFails } = await this.timedoutGasReturn({
...quote.trade,
gas: `0x${(quote.maxGas || MAX_GAS_LIMIT).toString(16)}`,
})
return [gasLimit, simulationFails, quote.aggregator]
}),
)
const newQuotes = {}
quoteGasData.forEach(([gasLimit, simulationFails, aggId]) => {
if (gasLimit && !simulationFails) {
const gasEstimateWithRefund = calculateGasEstimateWithRefund(quotes[aggId].maxGas, quotes[aggId].estimatedRefund, gasLimit)
newQuotes[aggId] = {
...quotes[aggId],
gasEstimate: gasLimit,
gasEstimateWithRefund,
}
} else if (quotes[aggId].approvalNeeded) {
// If gas estimation fails, but an ERC-20 approve is needed, then we do not add any estimate property to the quote object
// Such quotes will rely on the maxGas and averageGas properties from the api
newQuotes[aggId] = quotes[aggId]
}
// If gas estimation fails and no approval is needed, then we filter that quote out, so that it is not shown to the user
})
return newQuotes
}
timedoutGasReturn (tradeTxParams) {
return new Promise((resolve) => {
let gasTimedOut = false
const gasTimeout = setTimeout(() => {
gasTimedOut = true
resolve({ gasLimit: null, simulationFails: true })
}, 5000)
this.getBufferedGasLimit({ txParams: tradeTxParams }, 1)
.then(({ gasLimit, simulationFails }) => {
if (!gasTimedOut) {
clearTimeout(gasTimeout)
resolve({ gasLimit, simulationFails })
}
})
.catch((e) => {
log.error(e)
if (!gasTimedOut) {
clearTimeout(gasTimeout)
resolve({ gasLimit: null, simulationFails: true })
}
})
})
}
async setInitialGasEstimate (initialAggId, baseGasEstimate) {
const { swapsState } = this.store.getState()
const quoteToUpdate = { ...swapsState.quotes[initialAggId] }
const {
gasLimit: newGasEstimate,
simulationFails,
} = await this.timedoutGasReturn({
...quoteToUpdate.trade,
gas: baseGasEstimate,
})
if (newGasEstimate && !simulationFails) {
const gasEstimateWithRefund = calculateGasEstimateWithRefund(quoteToUpdate.maxGas, quoteToUpdate.estimatedRefund, newGasEstimate)
quoteToUpdate.gasEstimate = newGasEstimate
quoteToUpdate.gasEstimateWithRefund = gasEstimateWithRefund
}
this.store.updateState({
swapsState: { ...swapsState, quotes: { ...swapsState.quotes, [initialAggId]: quoteToUpdate } },
})
}
setApproveTxId (approveTxId) {
const { swapsState } = this.store.getState()
this.store.updateState({ swapsState: { ...swapsState, approveTxId } })
}
setTradeTxId (tradeTxId) {
const { swapsState } = this.store.getState()
this.store.updateState({ swapsState: { ...swapsState, tradeTxId } })
}
setMaxMode (maxMode) {
const { swapsState } = this.store.getState()
this.store.updateState({ swapsState: { ...swapsState, maxMode } })
}
setQuotesLastFetched (quotesLastFetched) {
const { swapsState } = this.store.getState()
this.store.updateState({ swapsState: { ...swapsState, quotesLastFetched } })
}
setSwapsTxGasPrice (gasPrice) {
const { swapsState } = this.store.getState()
this.store.updateState({
swapsState: { ...swapsState, customGasPrice: gasPrice },
})
}
setSwapsTxGasLimit (gasLimit) {
const { swapsState } = this.store.getState()
this.store.updateState({
swapsState: { ...swapsState, customMaxGas: gasLimit },
})
}
setCustomApproveTxData (data) {
const { swapsState } = this.store.getState()
this.store.updateState({
swapsState: { ...swapsState, customApproveTxData: data },
})
}
setBackgroundSwapRouteState (routeState) {
const { swapsState } = this.store.getState()
this.store.updateState({ swapsState: { ...swapsState, routeState } })
}
setSwapsLiveness (swapsFeatureIsLive) {
const { swapsState } = this.store.getState()
this.store.updateState({ swapsState: { ...swapsState, swapsFeatureIsLive } })
}
resetPostFetchState () {
const { swapsState } = this.store.getState()
this.store.updateState({
swapsState: {
...initialState.swapsState,
tokens: swapsState.tokens,
fetchParams: swapsState.fetchParams,
swapsFeatureIsLive: swapsState.swapsFeatureIsLive,
},
})
clearTimeout(this.pollingTimeout)
}
resetSwapsState () {
const { swapsState } = this.store.getState()
this.store.updateState({
swapsState: { ...initialState.swapsState, tokens: swapsState.tokens, swapsFeatureIsLive: swapsState.swapsFeatureIsLive },
})
clearTimeout(this.pollingTimeout)
}
async _getEthersGasPrice () {
const ethersGasPrice = await this.ethersProvider.getGasPrice()
return ethersGasPrice.toHexString()
}
async _findTopQuoteAggId (quotes) {
const tokenConversionRates = this.tokenRatesStore.getState()
.contractExchangeRates
const {
swapsState: { customGasPrice },
} = this.store.getState()
if (!Object.values(quotes).length) {
return {}
}
const usedGasPrice = customGasPrice || await this._getEthersGasPrice()
let topAggId = ''
let ethValueOfTradeForBestQuote = null
Object.values(quotes).forEach((quote) => {
const {
destinationAmount = 0,
destinationToken,
destinationTokenInfo,
trade,
approvalNeeded,
averageGas,
gasEstimate,
aggregator,
} = quote
const tradeGasLimitForCalculation = gasEstimate
? new BigNumber(gasEstimate, 16)
: new BigNumber(averageGas || MAX_GAS_LIMIT, 10)
const totalGasLimitForCalculation = tradeGasLimitForCalculation
.plus(approvalNeeded?.gas || '0x0', 16)
.toString(16)
const gasTotalInWeiHex = calcGasTotal(
totalGasLimitForCalculation,
usedGasPrice,
)
const totalEthCost = new BigNumber(gasTotalInWeiHex, 16).plus(
trade.value,
16,
)
const ethFee = conversionUtil(totalEthCost, {
fromCurrency: 'ETH',
fromDenomination: 'WEI',
toDenomination: 'ETH',
fromNumericBase: 'BN',
numberOfDecimals: 6,
})
const tokenConversionRate = tokenConversionRates[destinationToken]
const ethValueOfTrade =
destinationTokenInfo.symbol === 'ETH'
? calcTokenAmount(destinationAmount, 18).minus(ethFee, 10)
: new BigNumber(tokenConversionRate || 1, 10)
.times(
calcTokenAmount(
destinationAmount,
destinationTokenInfo.decimals,
),
10,
)
.minus(tokenConversionRate ? ethFee.toString(10) : 0, 10)
if (
ethValueOfTradeForBestQuote === null ||
ethValueOfTrade.gt(ethValueOfTradeForBestQuote)
) {
topAggId = aggregator
ethValueOfTradeForBestQuote = ethValueOfTrade
}
})
const isBest =
quotes[topAggId]?.destinationTokenInfo?.symbol === 'ETH' ||
Boolean(tokenConversionRates[quotes[topAggId]?.destinationToken])
return { topAggId, isBest }
}
async _getERC20Allowance (contractAddress, walletAddress) {
const contract = new ethers.Contract(
contractAddress, abi, this.ethersProvider,
)
return await contract.allowance(walletAddress, METASWAP_ADDRESS)
}
/**
* Sets up the fetching of the swaps feature liveness flag from our API.
* Performs an initial fetch when called, then fetches on a 10-minute
* interval.
*
* If the browser goes offline, the interval is cleared and swaps are disabled
* until the value can be fetched again.
*/
_setupSwapsLivenessFetching () {
const TEN_MINUTES_MS = 10 * 60 * 1000
let intervalId = null
const fetchAndSetupInterval = () => {
if (window.navigator.onLine && intervalId === null) {
// Set the interval first to prevent race condition between listener and
// initial call to this function.
intervalId = setInterval(this._fetchAndSetSwapsLiveness, TEN_MINUTES_MS)
this._fetchAndSetSwapsLiveness()
}
}
window.addEventListener('online', fetchAndSetupInterval)
window.addEventListener('offline', () => {
if (intervalId !== null) {
clearInterval(intervalId)
intervalId = null
const { swapsState } = this.store.getState()
if (swapsState.swapsFeatureIsLive) {
this.setSwapsLiveness(false)
}
}
})
fetchAndSetupInterval()
}
/**
* This function should only be called via _setupSwapsLivenessFetching.
*
* Attempts to fetch the swaps feature liveness flag from our API. Tries
* to fetch three times at 5-second intervals before giving up, in which
* case the value defaults to 'false'.
*
* Only updates state if the fetched/computed flag value differs from current
* state.
*/
async _fetchAndSetSwapsLiveness () {
const { swapsState } = this.store.getState()
const { swapsFeatureIsLive: oldSwapsFeatureIsLive } = swapsState
let swapsFeatureIsLive = false
let successfullyFetched = false
let numAttempts = 0
const fetchAndIncrementNumAttempts = async () => {
try {
swapsFeatureIsLive = Boolean(await this._fetchSwapsFeatureLiveness())
successfullyFetched = true
} catch (err) {
log.error(err)
numAttempts += 1
}
}
await fetchAndIncrementNumAttempts()
// The loop conditions are modified by fetchAndIncrementNumAttempts.
// eslint-disable-next-line no-unmodified-loop-condition
while (!successfullyFetched && numAttempts < 3) {
await new Promise((resolve) => {
setTimeout(resolve, 5000) // 5 seconds
})
await fetchAndIncrementNumAttempts()
}
if (!successfullyFetched) {
log.error('Failed to fetch swaps feature flag 3 times. Setting to false and trying again next interval.')
}
if (swapsFeatureIsLive !== oldSwapsFeatureIsLive) {
this.setSwapsLiveness(swapsFeatureIsLive)
}
}
async _modifyValuesForMaxEthMode (newQuotes, accountBalance) {
const {
swapsState: { customGasPrice },
} = this.store.getState()
const usedGasPrice = customGasPrice || await this._getEthersGasPrice()
const mappedNewQuotes = mapValues(newQuotes, (quote) => {
const oldSourceAmount = quote.sourceAmount
const gasTotalInWeiHex = calcGasTotal((new BigNumber(quote.maxGas, 10)).toString(16), usedGasPrice)
const newSourceAmount = (new BigNumber(accountBalance, 16)).minus(gasTotalInWeiHex, 16).toString(10)
const newOldRatio = (new BigNumber(newSourceAmount, 10)).div(oldSourceAmount, 10)
const oldNewDifference = (new BigNumber(oldSourceAmount, 10)).minus(newSourceAmount, 10)
const oldDestinationAmount = quote.destinationAmount
const newDestinationAmount = (new BigNumber(oldDestinationAmount, 10)).times(newOldRatio)
const oldValue = quote.trade.value
const newValue = (new BigNumber(oldValue, 16)).minus(oldNewDifference, 10)
return {
...quote,
trade: {
...quote.trade,
value: newValue,
},
destinationAmount: newDestinationAmount,
sourceAmount: newSourceAmount,
}
})
return mappedNewQuotes
}
}

View File

@ -15,10 +15,13 @@ import {
SEND_ETHER_ACTION_KEY,
DEPLOY_CONTRACT_ACTION_KEY,
CONTRACT_INTERACTION_KEY,
SWAP,
} from '../../../../ui/app/helpers/constants/transactions'
import cleanErrorStack from '../../lib/cleanErrorStack'
import { hexToBn, bnToHex, BnMultiplyByFraction } from '../../lib/util'
import { TRANSACTION_NO_CONTRACT_ERROR_KEY } from '../../../../ui/app/helpers/constants/error-keys'
import { getSwapsTokensReceivedFromTxMeta } from '../../../../ui/app/pages/swaps/swaps.util'
import { segment, METAMETRICS_ANONYMOUS_ID } from '../../lib/segment'
import TransactionStateManager from './tx-state-manager'
import TxGasUtil from './tx-gas-utils'
import PendingTransactionTracker from './pending-tx-tracker'
@ -72,11 +75,12 @@ export default class TransactionController extends EventEmitter {
this.blockTracker = opts.blockTracker
this.signEthTx = opts.signTransaction
this.inProcessOfSigning = new Set()
this.version = opts.version
this.memStore = new ObservableStore({})
this.query = new EthQuery(this.provider)
this.txGasUtil = new TxGasUtil(this.provider)
this.txGasUtil = new TxGasUtil(this.provider)
this._mapMethods()
this.txStateManager = new TransactionStateManager({
initState: opts.initState,
@ -359,6 +363,11 @@ export default class TransactionController extends EventEmitter {
type: TRANSACTION_TYPE_CANCEL,
})
if (originalTxMeta.transactionCategory === SWAP) {
newTxMeta.sourceTokenSymbol = originalTxMeta.sourceTokenSymbol
newTxMeta.destinationTokenSymbol = originalTxMeta.destinationTokenSymbol
}
this.addTx(newTxMeta)
await this.approveTransaction(newTxMeta.id)
return newTxMeta
@ -521,6 +530,10 @@ export default class TransactionController extends EventEmitter {
async publishTransaction (txId, rawTx) {
const txMeta = this.txStateManager.getTx(txId)
txMeta.rawTx = rawTx
if (txMeta.transactionCategory === SWAP) {
const preTxBalance = await this.query.getBalance(txMeta.txParams.from)
txMeta.preTxBalance = preTxBalance.toString(16)
}
this.txStateManager.updateTx(txMeta, 'transactions#publishTransaction')
let txHash
try {
@ -566,6 +579,57 @@ export default class TransactionController extends EventEmitter {
gasUsed,
}
if (txMeta.transactionCategory === SWAP) {
const postTxBalance = await this.query.getBalance(txMeta.txParams.from)
txMeta.postTxBalance = postTxBalance.toString(16)
}
if (txMeta.swapMetaData) {
let { version } = this.platform
if (process.env.METAMASK_ENVIRONMENT !== 'production') {
version = `${version}-${process.env.METAMASK_ENVIRONMENT}`
}
const segmentContext = {
app: {
version,
name: 'MetaMask Extension',
},
locale: this.preferencesStore.getState().currentLocale.replace('_', '-'),
page: {
path: '/background-process',
title: 'Background Process',
url: '/background-process',
},
userAgent: window.navigator.userAgent,
}
const metametricsId = this.preferencesStore.getState().metaMetricsId
if (metametricsId && txMeta.swapMetaData && txReceipt.status !== '0x0') {
segment.track({ event: 'Swap Completed', userId: metametricsId, context: segmentContext, category: 'swaps' })
segment.track({
event: 'Swap Completed',
properties: {
...txMeta.swapMetaData,
token_to_amount_received: getSwapsTokensReceivedFromTxMeta(txMeta.destinationTokenSymbol, txMeta, txMeta.destinationTokenAddress, txMeta.txParams.from, txMeta.destinationTokenDecimals),
},
context: segmentContext,
anonymousId: METAMETRICS_ANONYMOUS_ID,
excludeMetaMetricsId: true,
category: 'swaps',
})
} else if (metametricsId && txMeta.swapMetaData) {
segment.track({ event: 'Swap Failed', userId: metametricsId, context: segmentContext, category: 'swaps' })
segment.track({
event: 'Swap Failed',
properties: { ...txMeta.swapMetaData },
anonymousId: METAMETRICS_ANONYMOUS_ID,
excludeMetaMetricsId: true,
context: segmentContext,
category: 'swaps',
})
}
}
this.txStateManager.updateTx(txMeta, 'transactions#confirmTransaction - add txReceipt')
} catch (err) {
log.error(err)

View File

@ -1,5 +1,6 @@
import EthQuery from 'ethjs-query'
import log from 'loglevel'
import ethUtil from 'ethereumjs-util'
import { hexToBn, BnMultiplyByFraction, bnToHex } from '../../lib/util'
/**
@ -69,11 +70,11 @@ export default class TxGasUtil {
@param {string} blockGasLimitHex - the block gas limit
@returns {string} - the buffered gas limit as a hex string
*/
addGasBuffer (initialGasLimitHex, blockGasLimitHex) {
addGasBuffer (initialGasLimitHex, blockGasLimitHex, multiplier = 1.5) {
const initialGasLimitBn = hexToBn(initialGasLimitHex)
const blockGasLimitBn = hexToBn(blockGasLimitHex)
const upperGasLimitBn = blockGasLimitBn.muln(0.9)
const bufferedGasLimitBn = initialGasLimitBn.muln(1.5)
const bufferedGasLimitBn = initialGasLimitBn.muln(multiplier)
// if initialGasLimit is above blockGasLimit, dont modify it
if (initialGasLimitBn.gt(upperGasLimitBn)) {
@ -86,4 +87,12 @@ export default class TxGasUtil {
// otherwise use blockGasLimit
return bnToHex(upperGasLimitBn)
}
async getBufferedGasLimit (txMeta, multiplier) {
const { blockGasLimit, estimatedGasHex, simulationFails } = await this.analyzeGasUsage(txMeta)
// add additional gas buffer to our estimation for safety
const gasLimit = this.addGasBuffer(ethUtil.addHexPrefix(estimatedGasHex), blockGasLimit, multiplier)
return { gasLimit, simulationFails }
}
}

View File

@ -0,0 +1,27 @@
import Analytics from 'analytics-node'
const inDevelopment = process.env.METAMASK_DEBUG || process.env.IN_TEST
const flushAt = inDevelopment ? 1 : undefined
const segmentNoop = {
track () {
// noop
},
page () {
// noop
},
identify () {
// noop
},
}
// We do not want to track events on development builds unless specifically
// provided a SEGMENT_WRITE_KEY. This also holds true for test environments and
// E2E, which is handled in the build process by never providing the SEGMENT_WRITE_KEY
// which process.env.IN_TEST is true
export const segment = process.env.SEGMENT_WRITE_KEY
? new Analytics(process.env.SEGMENT_WRITE_KEY, { flushAt })
: segmentNoop
export const METAMETRICS_ANONYMOUS_ID = '0x0000000000000000'

View File

@ -2,7 +2,6 @@ import EventEmitter from 'events'
import pump from 'pump'
import Dnode from 'dnode'
import extension from 'extensionizer'
import ObservableStore from 'obs-store'
import asStream from 'obs-store/lib/asStream'
import RpcEngine from 'json-rpc-engine'
@ -50,6 +49,7 @@ import TypedMessageManager from './lib/typed-message-manager'
import TransactionController from './controllers/transactions'
import TokenRatesController from './controllers/token-rates'
import DetectTokensController from './controllers/detect-tokens'
import SwapsController from './controllers/swaps'
import { PermissionsController } from './controllers/permissions'
import getRestrictedMethods from './controllers/permissions/restrictedMethods'
import nodeify from './lib/nodeify'
@ -71,8 +71,10 @@ export default class MetamaskController extends EventEmitter {
this.sendUpdate = debounce(this.privateSendUpdate.bind(this), 200)
this.opts = opts
this.extension = opts.extension
this.platform = opts.platform
const initState = opts.initState || {}
const version = this.platform.getVersion()
this.recordFirstTimeInfo(initState)
// this keeps track of how many "controllerStream" connections are open
@ -92,6 +94,12 @@ export default class MetamaskController extends EventEmitter {
// lock to ensure only one vault created at once
this.createVaultMutex = new Mutex()
this.extension.runtime.onInstalled.addListener((details) => {
if (details.reason === 'update' && version === '8.1.0') {
this.platform.openExtensionInBrowser()
}
})
// next, we will initialize the controllers
// controller initialization order matters
@ -210,7 +218,6 @@ export default class MetamaskController extends EventEmitter {
preferencesStore: this.preferencesController.store,
})
const version = this.platform.getVersion()
this.threeBoxController = new ThreeBoxController({
preferencesController: this.preferencesController,
addressBookController: this.addressBookController,
@ -230,6 +237,7 @@ export default class MetamaskController extends EventEmitter {
signTransaction: this.keyringController.signTransaction.bind(this.keyringController),
provider: this.provider,
blockTracker: this.blockTracker,
version: this.platform.getVersion(),
})
this.txController.on('newUnapprovedTx', () => opts.showUnapprovedTx())
@ -267,6 +275,14 @@ export default class MetamaskController extends EventEmitter {
this.encryptionPublicKeyManager = new EncryptionPublicKeyManager()
this.typedMessageManager = new TypedMessageManager({ networkController: this.networkController })
this.swapsController = new SwapsController({
getBufferedGasLimit: this.txController.txGasUtil.getBufferedGasLimit.bind(this.txController.txGasUtil),
provider: this.provider,
getNetwork: this.networkController.getNetworkState.bind(this.networkController),
getProviderConfig: this.networkController.getProviderConfig.bind(this.networkController),
tokenRatesStore: this.tokenRatesController.store,
})
this.store.updateStructure({
AppStateController: this.appStateController.store,
TransactionController: this.txController.store,
@ -306,6 +322,7 @@ export default class MetamaskController extends EventEmitter {
PermissionsController: this.permissionsController.permissions,
PermissionsMetadata: this.permissionsController.store,
ThreeBoxController: this.threeBoxController.store,
SwapsController: this.swapsController.store,
// ENS Controller
EnsController: this.ensController.store,
})
@ -426,6 +443,7 @@ export default class MetamaskController extends EventEmitter {
preferencesController,
threeBoxController,
txController,
swapsController,
} = this
return {
@ -491,6 +509,7 @@ export default class MetamaskController extends EventEmitter {
setLastActiveTime: nodeify(this.appStateController.setLastActiveTime, this.appStateController),
setDefaultHomeActiveTabName: nodeify(this.appStateController.setDefaultHomeActiveTabName, this.appStateController),
setConnectedStatusPopoverHasBeenShown: nodeify(this.appStateController.setConnectedStatusPopoverHasBeenShown, this.appStateController),
setSwapsWelcomeMessageHasBeenShown: nodeify(this.appStateController.setSwapsWelcomeMessageHasBeenShown, this.appStateController),
// EnsController
tryReverseResolveAddress: nodeify(this.ensController.reverseResolveAddress, this.ensController),
@ -512,6 +531,7 @@ export default class MetamaskController extends EventEmitter {
estimateGas: nodeify(this.estimateGas, this),
getPendingNonce: nodeify(this.getPendingNonce, this),
getNextNonce: nodeify(this.getNextNonce, this),
addUnapprovedTransaction: nodeify(txController.addUnapprovedTransaction, txController),
// messageManager
signMessage: nodeify(this.signMessage, this),
@ -558,6 +578,25 @@ export default class MetamaskController extends EventEmitter {
addPermittedAccount: nodeify(permissionsController.addPermittedAccount, permissionsController),
removePermittedAccount: nodeify(permissionsController.removePermittedAccount, permissionsController),
requestAccountsPermissionWithId: nodeify(permissionsController.requestAccountsPermissionWithId, permissionsController),
// swaps
fetchAndSetQuotes: nodeify(swapsController.fetchAndSetQuotes, swapsController),
setSelectedQuoteAggId: nodeify(swapsController.setSelectedQuoteAggId, swapsController),
resetSwapsState: nodeify(swapsController.resetSwapsState, swapsController),
setSwapsTokens: nodeify(swapsController.setSwapsTokens, swapsController),
setApproveTxId: nodeify(swapsController.setApproveTxId, swapsController),
setTradeTxId: nodeify(swapsController.setTradeTxId, swapsController),
setMaxMode: nodeify(swapsController.setMaxMode, swapsController),
setSwapsTxGasPrice: nodeify(swapsController.setSwapsTxGasPrice, swapsController),
setSwapsTxGasLimit: nodeify(swapsController.setSwapsTxGasLimit, swapsController),
safeRefetchQuotes: nodeify(swapsController.safeRefetchQuotes, swapsController),
stopPollingForQuotes: nodeify(swapsController.stopPollingForQuotes, swapsController),
setBackgroundSwapRouteState: nodeify(swapsController.setBackgroundSwapRouteState, swapsController),
resetPostFetchState: nodeify(swapsController.resetPostFetchState, swapsController),
setSwapsErrorKey: nodeify(swapsController.setSwapsErrorKey, swapsController),
setInitialGasEstimate: nodeify(swapsController.setInitialGasEstimate, swapsController),
setCustomApproveTxData: nodeify(swapsController.setCustomApproveTxData, swapsController),
setSwapsLiveness: nodeify(swapsController.setSwapsLiveness, swapsController),
}
}
@ -1545,7 +1584,7 @@ export default class MetamaskController extends EventEmitter {
? 'metamask'
: (new URL(sender.url)).origin
let extensionId
if (sender.id !== extension.runtime.id) {
if (sender.id !== this.extension.runtime.id) {
extensionId = sender.id
}
let tabId

View File

@ -25,6 +25,7 @@
"test:e2e:firefox": "SELENIUM_BROWSER=firefox test/e2e/run-all.sh",
"test:coverage": "nyc --silent --check-coverage yarn test:unit:strict && nyc --silent --no-clean yarn test:unit:lax && nyc report --reporter=text --reporter=html",
"test:coverage:strict": "nyc --check-coverage yarn test:unit:strict",
"test:coverage:path": "nyc --check-coverage yarn test:unit:path",
"test:coveralls-upload": "if [ \"$COVERALLS_REPO_TOKEN\" ]; then nyc report --reporter=text-lcov | coveralls; fi",
"ganache:start": "./development/run-ganache",
"sentry:publish": "node ./development/sentry-publish.js",
@ -44,8 +45,8 @@
"devtools:redux": "remotedev --hostname=localhost --port=8000",
"start:dev": "concurrently -k -n build,react,redux yarn:start yarn:devtools:react yarn:devtools:redux",
"announce": "node development/announcer.js",
"storybook": "start-storybook -p 6006 -c .storybook --static-dir ./app",
"storybook:build": "build-storybook -c .storybook -o .out --static-dir ./app",
"storybook": "start-storybook -p 6006 -c .storybook --static-dir ./app, ./storybook/images",
"storybook:build": "build-storybook -c .storybook -o .out --static-dir ./app, ./storybook/images",
"storybook:deploy": "storybook-to-ghpages --existing-output-dir .out --remote storybook --branch master",
"update-changelog": "./development/auto-changelog.sh",
"generate:migration": "./development/generate-migration.sh"

View File

@ -5918,5 +5918,12 @@
],
"metametrics": {
"mockMetaMetricsResponse": true
},
"swaps": {
"featureFlag": {
"status": {
"active": true
}
}
}
}

View File

@ -495,5 +495,46 @@
},
"hasRetried": false,
"hasCancelled": false
},
{
"initialTransaction": {
"blockNumber": "6195527",
"id": 4243712234858467,
"metamaskNetworkId": "4",
"status": "confirmed",
"time": 1585088013000,
"txParams": {
"from": "0xee014609ef9e09776ac5fe00bdbfef57bcdefebb",
"gas": "0x5208",
"gasPrice": "0x77359400",
"nonce": "0x3",
"to": "0xabca64466f257793eaa52fcfff5066894b76a149",
"value": "0xde0b6b3a7640000"
},
"hash": "0xbcb195f393f4468945b4045cd41bcdbc2f19ad75ae92a32cf153a3004e42009a",
"transactionCategory": "swap"
},
"primaryTransaction": {
"blockNumber": "6195527",
"id": 4243712234858467,
"metamaskNetworkId": "4",
"status": "confirmed",
"time": 1585088013000,
"txParams": {
"from": "0xee014609ef9e09776ac5fe00bdbfef57bcdefebb",
"gas": "0x5208",
"gasPrice": "0x77359400",
"nonce": "0x3",
"to": "0xabca64466f257793eaa52fcfff5066894b76a149",
"value": "0xde0b6b3a7640000"
},
"hash": "0xbcb195f393f4468945b4045cd41bcdbc2f19ad75ae92a32cf153a3004e42009a",
"transactionCategory": "swap",
"destinationTokenSymbol": "ABC",
"destinationTokenAddress": "0xabca64466f257793eaa52fcfff5066894b76a149",
"sourceTokenSymbol": "ETH"
},
"hasRetried": false,
"hasCancelled": false
}
]

View File

@ -118,6 +118,12 @@ describe('MetaMask', function () {
await driver.clickElement(By.xpath(`//button[contains(text(), '${enLocaleMessages.endOfFlowMessage10.message}')]`))
await driver.delay(regularDelayMs)
})
it('closes the swaps intro popup', async function () {
await driver.findElement(By.css(`.popover-header__subtitle`))
await driver.clickElement(By.css('.popover-header__button'))
await driver.delay(regularDelayMs)
})
})
describe('Import seed phrase', function () {
@ -161,7 +167,7 @@ describe('MetaMask', function () {
describe('Adds an entry to the address book and sends eth to that address', function () {
it('starts a send transaction', async function () {
await driver.clickElement(By.xpath(`//button[contains(text(), 'Send')]`))
await driver.clickElement(By.css('[data-testid="eth-overview-send"]'))
await driver.delay(regularDelayMs)
const inputAddress = await driver.findElement(By.css('input[placeholder="Search, public address (0x), or ENS"]'))
@ -210,7 +216,7 @@ describe('MetaMask', function () {
describe('Sends to an address book entry', function () {
it('starts a send transaction by clicking address book entry', async function () {
await driver.clickElement(By.xpath(`//button[contains(text(), 'Send')]`))
await driver.clickElement(By.css('[data-testid="eth-overview-send"]'))
await driver.delay(regularDelayMs)
const recipientRowTitle = await driver.findElement(By.css('.send__select-recipient-wrapper__group-item__title'))

View File

@ -84,6 +84,10 @@ describe('MetaMask', function () {
await driver.clickElement(By.xpath(`//button[contains(text(), '${enLocaleMessages.remindMeLater.message}')]`))
await driver.delay(regularDelayMs)
await driver.findElement(By.css(`.popover-header__subtitle`))
await driver.clickElement(By.css('.popover-header__button'))
await driver.delay(regularDelayMs)
await driver.clickElement(By.css('[data-testid="account-options-menu-button"]'))
await driver.clickElement(By.css('[data-testid="account-options-menu__account-details"]'))
})

View File

@ -1,7 +1,8 @@
{
"data": {
"AppStateController": {
"mkrMigrationReminderTimestamp": null
"mkrMigrationReminderTimestamp": null,
"swapsWelcomeMessageHasBeenShown": true
},
"CachedBalancesController": {
"cachedBalances": {

View File

@ -1,7 +1,8 @@
{
"data": {
"AppStateController": {
"mkrMigrationReminderTimestamp": null
"mkrMigrationReminderTimestamp": null,
"swapsWelcomeMessageHasBeenShown": true
},
"CachedBalancesController": {
"cachedBalances": {

View File

@ -1,7 +1,8 @@
{
"data": {
"AppStateController": {
"connectedStatusPopoverHasBeenShown": false
"connectedStatusPopoverHasBeenShown": false,
"swapsWelcomeMessageHasBeenShown": true
},
"CachedBalancesController": {
"cachedBalances": {

View File

@ -93,6 +93,12 @@ describe('Using MetaMask with an existing account', function () {
await driver.clickElement(By.xpath(`//button[contains(text(), '${enLocaleMessages.endOfFlowMessage10.message}')]`))
await driver.delay(regularDelayMs)
})
it('closes the swaps intro popup', async function () {
await driver.findElement(By.css(`.popover-header__subtitle`))
await driver.clickElement(By.css('.popover-header__button'))
await driver.delay(regularDelayMs)
})
})
describe('Show account information', function () {
@ -186,7 +192,7 @@ describe('Using MetaMask with an existing account', function () {
describe('Send ETH from inside MetaMask', function () {
it('starts a send transaction', async function () {
await driver.clickElement(By.xpath(`//button[contains(text(), 'Send')]`))
await driver.clickElement(By.css('[data-testid="eth-overview-send"]'))
await driver.delay(regularDelayMs)
const inputAddress = await driver.findElement(By.css('input[placeholder="Search, public address (0x), or ENS"]'))

View File

@ -90,6 +90,10 @@ describe('MetaMask', function () {
await driver.clickElement(By.xpath(`//button[contains(text(), '${enLocaleMessages.remindMeLater.message}')]`))
await driver.delay(regularDelayMs)
await driver.findElement(By.css(`.popover-header__subtitle`))
await driver.clickElement(By.css('.popover-header__button'))
await driver.delay(regularDelayMs)
await driver.clickElement(By.css('[data-testid="account-options-menu-button"]'))
await driver.clickElement(By.css('[data-testid="account-options-menu__account-details"]'))
})

View File

@ -113,6 +113,12 @@ describe('MetaMask', function () {
await driver.clickElement(By.xpath(`//button[contains(text(), '${enLocaleMessages.endOfFlowMessage10.message}')]`))
await driver.delay(regularDelayMs)
})
it('closes the swaps intro popup', async function () {
await driver.findElement(By.css(`.popover-header__subtitle`))
await driver.clickElement(By.css('.popover-header__button'))
await driver.delay(regularDelayMs)
})
})
describe('Show account information', function () {
@ -176,7 +182,7 @@ describe('MetaMask', function () {
describe('Send ETH from inside MetaMask', function () {
it('starts to send a transaction', async function () {
await driver.clickElement(By.xpath(`//button[contains(text(), 'Send')]`))
await driver.clickElement(By.css('[data-testid="eth-overview-send"]'))
await driver.delay(regularDelayMs)
const inputAddress = await driver.findElement(By.css('input[placeholder="Search, public address (0x), or ENS"]'))

View File

@ -115,6 +115,12 @@ describe('MetaMask', function () {
await driver.clickElement(By.xpath(`//button[contains(text(), '${enLocaleMessages.endOfFlowMessage10.message}')]`))
await driver.delay(regularDelayMs)
})
it('closes the swaps intro popup', async function () {
await driver.findElement(By.css(`.popover-header__subtitle`))
await driver.clickElement(By.css('.popover-header__button'))
await driver.delay(regularDelayMs)
})
})
describe('Show account information', function () {
@ -217,7 +223,7 @@ describe('MetaMask', function () {
describe('Send ETH from inside MetaMask using default gas', function () {
it('starts a send transaction', async function () {
await driver.clickElement(By.xpath(`//button[contains(text(), 'Send')]`))
await driver.clickElement(By.css('[data-testid="eth-overview-send"]'))
await driver.delay(regularDelayMs)
const inputAddress = await driver.findElement(By.css('input[placeholder="Search, public address (0x), or ENS"]'))
@ -281,7 +287,7 @@ describe('MetaMask', function () {
describe('Send ETH from inside MetaMask using fast gas option', function () {
it('starts a send transaction', async function () {
await driver.clickElement(By.xpath(`//button[contains(text(), 'Send')]`))
await driver.clickElement(By.css('[data-testid="eth-overview-send"]'))
await driver.delay(regularDelayMs)
const inputAddress = await driver.findElement(By.css('input[placeholder="Search, public address (0x), or ENS"]'))
@ -320,7 +326,7 @@ describe('MetaMask', function () {
describe('Send ETH from inside MetaMask using advanced gas modal', function () {
it('starts a send transaction', async function () {
await driver.clickElement(By.xpath(`//button[contains(text(), 'Send')]`))
await driver.clickElement(By.css('[data-testid="eth-overview-send"]'))
await driver.delay(regularDelayMs)
const inputAddress = await driver.findElement(By.css('input[placeholder="Search, public address (0x), or ENS"]'))
@ -842,7 +848,7 @@ describe('MetaMask', function () {
describe('Send token from inside MetaMask', function () {
let gasModal
it('starts to send a transaction', async function () {
await driver.clickElement(By.xpath(`//button[contains(text(), 'Send')]`))
await driver.clickElement(By.css('[data-testid="eth-overview-send"]'))
await driver.delay(regularDelayMs)
const inputAddress = await driver.findElement(By.css('input[placeholder="Search, public address (0x), or ENS"]'))

View File

@ -85,6 +85,10 @@ describe('MetaMask', function () {
await driver.clickElement(By.xpath(`//button[contains(text(), '${enLocaleMessages.remindMeLater.message}')]`))
await driver.delay(regularDelayMs)
await driver.findElement(By.css(`.popover-header__subtitle`))
await driver.clickElement(By.css('.popover-header__button'))
await driver.delay(regularDelayMs)
await driver.clickElement(By.css('[data-testid="account-options-menu-button"]'))
await driver.clickElement(By.css('[data-testid="account-options-menu__account-details"]'))
})

View File

@ -91,11 +91,17 @@ describe('Using MetaMask with an existing account', function () {
await driver.clickElement(By.xpath(`//button[contains(text(), '${enLocaleMessages.endOfFlowMessage10.message}')]`))
await driver.delay(regularDelayMs)
})
it('closes the swaps intro popup', async function () {
await driver.findElement(By.css(`.popover-header__subtitle`))
await driver.clickElement(By.css('.popover-header__button'))
await driver.delay(regularDelayMs)
})
})
describe('Send ETH from inside MetaMask', function () {
it('starts a send transaction', async function () {
await driver.clickElement(By.xpath(`//button[contains(text(), 'Send')]`))
await driver.clickElement(By.css('[data-testid="eth-overview-send"]'))
await driver.delay(regularDelayMs)
const inputAddress = await driver.findElement(By.css('input[placeholder="Search, public address (0x), or ENS"]'))

View File

@ -95,6 +95,12 @@ describe('MetaMask', function () {
await driver.delay(regularDelayMs)
})
it('closes the swaps intro popup', async function () {
await driver.findElement(By.css(`.popover-header__subtitle`))
await driver.clickElement(By.css('.popover-header__button'))
await driver.delay(regularDelayMs)
})
it('balance renders', async function () {
const balance = await driver.findElement(By.css('[data-testid="wallet-balance"] .list-item__heading'))
await driver.wait(until.elementTextMatches(balance, /25\s*ETH/u))
@ -201,6 +207,12 @@ describe('MetaMask', function () {
await driver2.delay(regularDelayMs)
})
it('closes the swaps intro popup', async function () {
await driver2.findElement(By.css(`.popover-header__subtitle`))
await driver2.clickElement(By.css('.popover-header__button'))
await driver2.delay(regularDelayMs)
})
it('balance renders', async function () {
const balance = await driver2.findElement(By.css('[data-testid="wallet-balance"] .list-item__heading'))
await driver2.wait(until.elementTextMatches(balance, /25\s*ETH/u))

View File

@ -47,6 +47,10 @@ async function setupFetchMocking (driver) {
return { json: async () => clone(mockResponses.ethGasPredictTable) }
} else if (url.match(/chromeextensionmm/u)) {
return { json: async () => clone(mockResponses.metametrics) }
} else if (url.match(/^https:\/\/(api\.metaswap|.*airswap-dev)/u)) {
if (url.match(/featureFlag$/u)) {
return { json: async () => clone(mockResponses.swaps.featureFlag) }
}
}
return window.origFetch(...args)
}

View File

@ -32,6 +32,9 @@ class ThreeBoxControllerMock {
const ExtensionizerMock = {
runtime: {
id: 'fake-extension-id',
onInstalled: {
addListener: () => undefined,
},
},
}
@ -60,7 +63,6 @@ const createLoggerMiddlewareMock = () => (req, res, next) => {
const MetaMaskController = proxyquire('../../../../app/scripts/metamask-controller', {
'./controllers/threebox': { default: ThreeBoxControllerMock },
'extensionizer': ExtensionizerMock,
'./lib/createLoggerMiddleware': { default: createLoggerMiddlewareMock },
}).default
@ -101,6 +103,7 @@ describe('MetaMaskController', function () {
},
initState: cloneDeep(firstTimeState),
platform: { showTransactionNotification: () => undefined, getVersion: () => 'foo' },
extension: ExtensionizerMock,
infuraProjectId: 'foo',
})

View File

@ -0,0 +1,745 @@
import assert from 'assert'
import sinon from 'sinon'
import { ethers } from 'ethers'
import BigNumber from 'bignumber.js'
import ObservableStore from 'obs-store'
import { createTestProviderTools } from '../../../stub/provider'
import { DEFAULT_ERC20_APPROVE_GAS } from '../../../../ui/app/helpers/constants/swaps'
import SwapsController from '../../../../app/scripts/controllers/swaps'
const MOCK_FETCH_PARAMS = {
slippage: 3,
sourceToken: '0x6b175474e89094c44da98b954eedeac495271d0f',
sourceDecimals: 18,
destinationToken: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
value: '1000000000000000000',
fromAddress: '0x7F18BB4Dd92CF2404C54CBa1A9BE4A1153bdb078',
exchangeList: 'zeroExV1',
}
const TEST_AGG_ID = 'zeroExV1'
const MOCK_QUOTES = {
[TEST_AGG_ID]: {
trade: {
data: '0x00',
from: '0x7F18BB4Dd92CF2404C54CBa1A9BE4A1153bdb078',
value: '0x17647444f166000',
gas: '0xe09c0',
gasPrice: undefined,
to: '0x016B4bf68d421147c06f1b8680602c5bf0Df91A8',
},
sourceAmount: '1000000000000000000000000000000000000',
destinationAmount: '396493201125465',
error: null,
sourceToken: '0x6b175474e89094c44da98b954eedeac495271d0f',
destinationToken: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
approvalNeeded: null,
maxGas: 920000,
averageGas: 312510,
estimatedRefund: 343090,
fetchTime: 559,
aggregator: TEST_AGG_ID,
aggType: 'AGG',
slippage: 3,
},
}
const MOCK_FETCH_METADATA = {
destinationTokenInfo: {
symbol: 'FOO',
decimals: 18,
},
}
const MOCK_TOKEN_RATES_STORE = new ObservableStore({
contractExchangeRates: { '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': 2 },
})
const MOCK_GET_PROVIDER_CONFIG = () => ({ type: 'FAKE_NETWORK' })
const MOCK_GET_BUFFERED_GAS_LIMIT = async () => ({
gasLimit: 2000000,
simulationFails: undefined,
})
const EMPTY_INIT_STATE = {
swapsState: {
quotes: {},
fetchParams: null,
tokens: null,
tradeTxId: null,
approveTxId: null,
maxMode: false,
quotesLastFetched: null,
customMaxGas: '',
customGasPrice: null,
selectedAggId: null,
customApproveTxData: '',
errorKey: '',
topAggId: null,
routeState: '',
swapsFeatureIsLive: false,
},
}
const sandbox = sinon.createSandbox()
const fetchTradesInfoStub = sandbox.stub()
const fetchSwapsFeatureLivenessStub = sandbox.stub()
describe('SwapsController', function () {
let provider
const getSwapsController = () => {
return new SwapsController({
getBufferedGasLimit: MOCK_GET_BUFFERED_GAS_LIMIT,
provider,
getProviderConfig: MOCK_GET_PROVIDER_CONFIG,
tokenRatesStore: MOCK_TOKEN_RATES_STORE,
fetchTradesInfo: fetchTradesInfoStub,
fetchSwapsFeatureLiveness: fetchSwapsFeatureLivenessStub,
})
}
before(function () {
const providerResultStub = {
// 1 gwei
eth_gasPrice: '0x0de0b6b3a7640000',
// by default, all accounts are external accounts (not contracts)
eth_getCode: '0x',
}
provider = createTestProviderTools({ scaffold: providerResultStub })
.provider
})
afterEach(function () {
sandbox.restore()
})
describe('constructor', function () {
it('should setup correctly', function () {
const swapsController = getSwapsController()
assert.deepStrictEqual(swapsController.store.getState(), EMPTY_INIT_STATE)
assert.deepStrictEqual(
swapsController.getBufferedGasLimit,
MOCK_GET_BUFFERED_GAS_LIMIT,
)
assert.strictEqual(swapsController.pollCount, 0)
assert.deepStrictEqual(
swapsController.getProviderConfig,
MOCK_GET_PROVIDER_CONFIG,
)
})
})
describe('API', function () {
let swapsController
beforeEach(function () {
swapsController = getSwapsController()
})
describe('setters', function () {
it('should set selected quote agg id', function () {
const selectedAggId = 'test'
swapsController.setSelectedQuoteAggId(selectedAggId)
assert.deepStrictEqual(
swapsController.store.getState().swapsState.selectedAggId,
selectedAggId,
)
})
it('should set swaps tokens', function () {
const tokens = []
swapsController.setSwapsTokens(tokens)
assert.deepStrictEqual(
swapsController.store.getState().swapsState.tokens,
tokens,
)
})
it('should set trade tx id', function () {
const tradeTxId = 'test'
swapsController.setTradeTxId(tradeTxId)
assert.strictEqual(
swapsController.store.getState().swapsState.tradeTxId,
tradeTxId,
)
})
it('should set max mode', function () {
const maxMode = true
swapsController.setMaxMode(maxMode)
assert.strictEqual(
swapsController.store.getState().swapsState.maxMode,
maxMode,
)
})
it('should set swaps tx gas price', function () {
const gasPrice = 1
swapsController.setSwapsTxGasPrice(gasPrice)
assert.deepStrictEqual(
swapsController.store.getState().swapsState.customGasPrice,
gasPrice,
)
})
it('should set swaps tx gas limit', function () {
const gasLimit = '1'
swapsController.setSwapsTxGasLimit(gasLimit)
assert.deepStrictEqual(
swapsController.store.getState().swapsState.customMaxGas,
gasLimit,
)
})
it('should set background swap route state', function () {
const routeState = 'test'
swapsController.setBackgroundSwapRouteState(routeState)
assert.deepStrictEqual(
swapsController.store.getState().swapsState.routeState,
routeState,
)
})
it('should set swaps error key', function () {
const errorKey = 'test'
swapsController.setSwapsErrorKey(errorKey)
assert.deepStrictEqual(
swapsController.store.getState().swapsState.errorKey,
errorKey,
)
})
it('should set initial gas estimate', async function () {
const initialAggId = TEST_AGG_ID
const baseGasEstimate = 10
const { maxGas, estimatedRefund } = MOCK_QUOTES[TEST_AGG_ID]
const { swapsState } = swapsController.store.getState()
// Set mock quotes in order to have data for the test agg
swapsController.store.updateState({
swapsState: { ...swapsState, quotes: MOCK_QUOTES },
})
await swapsController.setInitialGasEstimate(
initialAggId,
baseGasEstimate,
)
const {
gasLimit: bufferedGasLimit,
} = await swapsController.getBufferedGasLimit()
const {
gasEstimate,
gasEstimateWithRefund,
} = swapsController.store.getState().swapsState.quotes[initialAggId]
assert.strictEqual(gasEstimate, bufferedGasLimit)
assert.strictEqual(
gasEstimateWithRefund,
new BigNumber(maxGas, 10).minus(estimatedRefund, 10).toString(16),
)
})
it('should set custom approve tx data', function () {
const data = 'test'
swapsController.setCustomApproveTxData(data)
assert.deepStrictEqual(
swapsController.store.getState().swapsState.customApproveTxData,
data,
)
})
})
describe('fetchAndSetQuotes', function () {
it('returns null if fetchParams is not provided', async function () {
const quotes = await swapsController.fetchAndSetQuotes(undefined)
assert.strictEqual(quotes, null)
})
it('calls fetchTradesInfo with the given fetchParams and returns the correct quotes', async function () {
fetchTradesInfoStub.resolves(MOCK_QUOTES)
// Make it so approval is not required
sandbox
.stub(swapsController, '_getERC20Allowance')
.resolves(ethers.BigNumber.from(1))
const [newQuotes] = await swapsController.fetchAndSetQuotes(
MOCK_FETCH_PARAMS,
MOCK_FETCH_METADATA,
)
assert.deepStrictEqual(newQuotes[TEST_AGG_ID], {
...MOCK_QUOTES[TEST_AGG_ID],
sourceTokenInfo: undefined,
destinationTokenInfo: {
symbol: 'FOO',
decimals: 18,
},
isBestQuote: true,
// TODO: find a way to calculate these values dynamically
gasEstimate: 2000000,
gasEstimateWithRefund: '8cd8e',
})
assert.strictEqual(
fetchTradesInfoStub.calledOnceWithExactly(MOCK_FETCH_PARAMS),
true,
)
})
it('performs the allowance check', async function () {
fetchTradesInfoStub.resolves(MOCK_QUOTES)
// Make it so approval is not required
const allowanceStub = sandbox
.stub(swapsController, '_getERC20Allowance')
.resolves(ethers.BigNumber.from(1))
await swapsController.fetchAndSetQuotes(
MOCK_FETCH_PARAMS,
MOCK_FETCH_METADATA,
)
assert.strictEqual(
allowanceStub.calledOnceWithExactly(
MOCK_FETCH_PARAMS.sourceToken,
MOCK_FETCH_PARAMS.fromAddress,
),
true,
)
})
it('gets the gas limit if approval is required', async function () {
fetchTradesInfoStub.resolves(MOCK_QUOTES)
// Ensure approval is required
sandbox
.stub(swapsController, '_getERC20Allowance')
.resolves(ethers.BigNumber.from(0))
const timedoutGasReturnResult = { gasLimit: 1000000 }
const timedoutGasReturnStub = sandbox
.stub(swapsController, 'timedoutGasReturn')
.resolves(timedoutGasReturnResult)
await swapsController.fetchAndSetQuotes(
MOCK_FETCH_PARAMS,
MOCK_FETCH_METADATA,
)
// Mocked quotes approvalNeeded is null, so it will only be called with the gas
assert.strictEqual(
timedoutGasReturnStub.calledOnceWithExactly({
gas: DEFAULT_ERC20_APPROVE_GAS,
}),
true,
)
})
it('marks the best quote', async function () {
fetchTradesInfoStub.resolves(MOCK_QUOTES)
// Make it so approval is not required
sandbox
.stub(swapsController, '_getERC20Allowance')
.resolves(ethers.BigNumber.from(1))
const [newQuotes, topAggId] = await swapsController.fetchAndSetQuotes(
MOCK_FETCH_PARAMS,
MOCK_FETCH_METADATA,
)
assert.strictEqual(topAggId, TEST_AGG_ID)
assert.strictEqual(newQuotes[topAggId].isBestQuote, true)
})
it('selects the best quote', async function () {
const bestAggId = 'bestAggId'
// Clone the existing mock quote and increase destination amount
const bestQuote = {
...MOCK_QUOTES[TEST_AGG_ID],
aggregator: bestAggId,
destinationAmount: ethers.BigNumber.from(
MOCK_QUOTES[TEST_AGG_ID].destinationAmount,
)
.add(1)
.toString(),
}
const quotes = { ...MOCK_QUOTES, [bestAggId]: bestQuote }
fetchTradesInfoStub.resolves(quotes)
// Make it so approval is not required
sandbox
.stub(swapsController, '_getERC20Allowance')
.resolves(ethers.BigNumber.from(1))
const [newQuotes, topAggId] = await swapsController.fetchAndSetQuotes(
MOCK_FETCH_PARAMS,
MOCK_FETCH_METADATA,
)
assert.strictEqual(topAggId, bestAggId)
assert.strictEqual(newQuotes[topAggId].isBestQuote, true)
})
it('does not set isBestQuote if no conversion rate exists for destination token', async function () {
fetchTradesInfoStub.resolves(MOCK_QUOTES)
// Make it so approval is not required
sandbox
.stub(swapsController, '_getERC20Allowance')
.resolves(ethers.BigNumber.from(1))
swapsController.tokenRatesStore.updateState({
contractExchangeRates: {},
})
const [newQuotes, topAggId] = await swapsController.fetchAndSetQuotes(
MOCK_FETCH_PARAMS,
MOCK_FETCH_METADATA,
)
assert.strictEqual(newQuotes[topAggId].isBestQuote, undefined)
})
})
describe('resetSwapsState', function () {
it('resets the swaps state correctly', function () {
const { swapsState: old } = swapsController.store.getState()
swapsController.resetSwapsState()
const { swapsState } = swapsController.store.getState()
assert.deepStrictEqual(swapsState, {
...EMPTY_INIT_STATE.swapsState,
tokens: old.tokens,
})
})
it('clears polling timeout', function () {
swapsController.pollingTimeout = setTimeout(
() => assert.fail(),
1000000,
)
swapsController.resetSwapsState()
assert.strictEqual(swapsController.pollingTimeout._idleTimeout, -1)
})
})
describe('stopPollingForQuotes', function () {
it('clears polling timeout', function () {
swapsController.pollingTimeout = setTimeout(
() => assert.fail(),
1000000,
)
swapsController.stopPollingForQuotes()
assert.strictEqual(swapsController.pollingTimeout._idleTimeout, -1)
})
it('resets quotes state correctly', function () {
swapsController.stopPollingForQuotes()
const { swapsState } = swapsController.store.getState()
assert.deepStrictEqual(swapsState.quotes, {})
assert.strictEqual(swapsState.quotesLastFetched, null)
})
})
describe('resetPostFetchState', function () {
it('clears polling timeout', function () {
swapsController.pollingTimeout = setTimeout(
() => assert.fail(),
1000000,
)
swapsController.resetPostFetchState()
assert.strictEqual(swapsController.pollingTimeout._idleTimeout, -1)
})
it('updates state correctly', function () {
const tokens = 'test'
const fetchParams = 'test'
const swapsFeatureIsLive = false
swapsController.store.updateState({
swapsState: { tokens, fetchParams, swapsFeatureIsLive },
})
swapsController.resetPostFetchState()
const { swapsState } = swapsController.store.getState()
assert.deepStrictEqual(swapsState, {
...EMPTY_INIT_STATE.swapsState,
tokens,
fetchParams,
swapsFeatureIsLive,
})
})
})
describe('_setupSwapsLivenessFetching ', function () {
let clock
const EXPECTED_TIME = 600000
const getLivenessState = () => {
return swapsController.store.getState().swapsState.swapsFeatureIsLive
}
// We have to do this to overwrite window.navigator.onLine
const stubWindow = () => {
sandbox.replace(global, 'window', {
addEventListener: window.addEventListener,
navigator: { onLine: true },
dispatchEvent: window.dispatchEvent,
Event: window.Event,
})
}
beforeEach(function () {
stubWindow()
clock = sandbox.useFakeTimers()
sandbox.spy(clock, 'setInterval')
sandbox.stub(
SwapsController.prototype,
'_fetchAndSetSwapsLiveness',
).resolves(undefined)
sandbox.spy(
SwapsController.prototype,
'_setupSwapsLivenessFetching',
)
sandbox.spy(window, 'addEventListener')
})
afterEach(function () {
sandbox.restore()
})
it('calls _setupSwapsLivenessFetching in constructor', function () {
swapsController = getSwapsController()
assert.ok(
swapsController._setupSwapsLivenessFetching.calledOnce,
'should have called _setupSwapsLivenessFetching once',
)
assert.ok(
window.addEventListener.calledWith('online'),
)
assert.ok(
window.addEventListener.calledWith('offline'),
)
assert.ok(
clock.setInterval.calledOnceWithExactly(
sinon.match.func,
EXPECTED_TIME,
),
'should have set an interval',
)
})
it('handles browser being offline on boot, then coming online', async function () {
window.navigator.onLine = false
swapsController = getSwapsController()
assert.ok(
swapsController._setupSwapsLivenessFetching.calledOnce,
'should have called _setupSwapsLivenessFetching once',
)
assert.ok(
swapsController._fetchAndSetSwapsLiveness.notCalled,
'should not have called _fetchAndSetSwapsLiveness',
)
assert.ok(
clock.setInterval.notCalled,
'should not have set an interval',
)
assert.strictEqual(
getLivenessState(), false,
'swaps feature should be disabled',
)
const fetchPromise = new Promise((resolve) => {
const originalFunction = swapsController._fetchAndSetSwapsLiveness
swapsController._fetchAndSetSwapsLiveness = () => {
originalFunction()
resolve()
swapsController._fetchAndSetSwapsLiveness = originalFunction
}
})
// browser comes online
window.navigator.onLine = true
window.dispatchEvent(new window.Event('online'))
await fetchPromise
assert.ok(
swapsController._fetchAndSetSwapsLiveness.calledOnce,
'should have called _fetchAndSetSwapsLiveness once',
)
assert.ok(
clock.setInterval.calledOnceWithExactly(
sinon.match.func,
EXPECTED_TIME,
),
'should have set an interval',
)
})
it('clears interval if browser goes offline', async function () {
swapsController = getSwapsController()
// set feature to live
const { swapsState } = swapsController.store.getState()
swapsController.store.updateState({
swapsState: { ...swapsState, swapsFeatureIsLive: true },
})
sandbox.spy(swapsController.store, 'updateState')
assert.ok(
clock.setInterval.calledOnceWithExactly(
sinon.match.func,
EXPECTED_TIME,
),
'should have set an interval',
)
const clearIntervalPromise = new Promise((resolve) => {
const originalFunction = clock.clearInterval
clock.clearInterval = (intervalId) => {
originalFunction(intervalId)
clock.clearInterval = originalFunction
resolve()
}
})
// browser goes offline
window.navigator.onLine = false
window.dispatchEvent(new window.Event('offline'))
// if this resolves, clearInterval was called
await clearIntervalPromise
assert.ok(
swapsController._fetchAndSetSwapsLiveness.calledOnce,
'should have called _fetchAndSetSwapsLiveness once',
)
assert.ok(
swapsController.store.updateState.calledOnce,
'should have called updateState once',
)
assert.strictEqual(
getLivenessState(), false,
'swaps feature should be disabled',
)
})
})
describe('_fetchAndSetSwapsLiveness', function () {
const getLivenessState = () => {
return swapsController.store.getState().swapsState.swapsFeatureIsLive
}
beforeEach(function () {
fetchSwapsFeatureLivenessStub.reset()
sandbox.stub(
SwapsController.prototype,
'_setupSwapsLivenessFetching',
)
swapsController = getSwapsController()
})
afterEach(function () {
sandbox.restore()
})
it('fetches feature liveness as expected when API is live', async function () {
fetchSwapsFeatureLivenessStub.resolves(true)
assert.strictEqual(
getLivenessState(), false, 'liveness should be false on boot',
)
await swapsController._fetchAndSetSwapsLiveness()
assert.ok(
fetchSwapsFeatureLivenessStub.calledOnce,
'should have called fetch function once',
)
assert.strictEqual(
getLivenessState(), true, 'liveness should be true after call',
)
})
it('does not update state if fetched value is same as state value', async function () {
fetchSwapsFeatureLivenessStub.resolves(false)
sandbox.spy(swapsController.store, 'updateState')
assert.strictEqual(
getLivenessState(), false, 'liveness should be false on boot',
)
await swapsController._fetchAndSetSwapsLiveness()
assert.ok(
fetchSwapsFeatureLivenessStub.calledOnce,
'should have called fetch function once',
)
assert.ok(
swapsController.store.updateState.notCalled,
'should not have called store.updateState',
)
assert.strictEqual(
getLivenessState(), false, 'liveness should remain false after call',
)
})
it('tries three times before giving up if fetching fails', async function () {
const clock = sandbox.useFakeTimers()
fetchSwapsFeatureLivenessStub.rejects(new Error('foo'))
sandbox.spy(swapsController.store, 'updateState')
assert.strictEqual(
getLivenessState(), false, 'liveness should be false on boot',
)
swapsController._fetchAndSetSwapsLiveness()
await clock.runAllAsync()
assert.ok(
fetchSwapsFeatureLivenessStub.calledThrice,
'should have called fetch function three times',
)
assert.ok(
swapsController.store.updateState.notCalled,
'should not have called store.updateState',
)
assert.strictEqual(
getLivenessState(), false, 'liveness should remain false after call',
)
})
it('sets state after fetching on successful retry', async function () {
const clock = sandbox.useFakeTimers()
fetchSwapsFeatureLivenessStub.onCall(0).rejects(new Error('foo'))
fetchSwapsFeatureLivenessStub.onCall(1).rejects(new Error('foo'))
fetchSwapsFeatureLivenessStub.onCall(2).resolves(true)
assert.strictEqual(
getLivenessState(), false, 'liveness should be false on boot',
)
swapsController._fetchAndSetSwapsLiveness()
await clock.runAllAsync()
assert.strictEqual(
fetchSwapsFeatureLivenessStub.callCount, 3,
'should have called fetch function three times',
)
assert.strictEqual(
getLivenessState(), true, 'liveness should be true after call',
)
})
})
})
})

View File

@ -16,6 +16,13 @@ const { provider } = createTestProviderTools({ scaffold: {} })
const middleware = [thunk]
const defaultState = { metamask: {} }
const mockStore = (state = defaultState) => configureStore(middleware)(state)
const extensionMock = {
runtime: {
onInstalled: {
addListener: () => undefined,
},
},
}
describe('Actions', function () {
@ -32,6 +39,7 @@ describe('Actions', function () {
beforeEach(async function () {
metamaskController = new MetaMaskController({
extension: extensionMock,
platform: { getVersion: () => 'foo' },
provider,
keyringController: new KeyringController({}),

View File

@ -19,7 +19,9 @@ export default class AppHeader extends PureComponent {
isUnlocked: PropTypes.bool,
hideNetworkIndicator: PropTypes.bool,
disabled: PropTypes.bool,
disableNetworkIndicator: PropTypes.bool,
isAccountMenuOpen: PropTypes.bool,
onClick: PropTypes.func,
}
static contextTypes = {
@ -84,7 +86,9 @@ export default class AppHeader extends PureComponent {
provider,
isUnlocked,
hideNetworkIndicator,
disableNetworkIndicator,
disabled,
onClick,
} = this.props
return (
@ -94,7 +98,12 @@ export default class AppHeader extends PureComponent {
<div className="app-header__contents">
<MetaFoxLogo
unsetIconHeight
onClick={() => history.push(DEFAULT_ROUTE)}
onClick={async () => {
if (onClick) {
await onClick()
}
history.push(DEFAULT_ROUTE)
}}
/>
<div className="app-header__account-menu-container">
{
@ -104,7 +113,7 @@ export default class AppHeader extends PureComponent {
network={network}
provider={provider}
onClick={(event) => this.handleNetworkIndicatorClick(event)}
disabled={disabled}
disabled={disabled || disableNetworkIndicator}
/>
</div>
)

View File

@ -17,6 +17,7 @@ export default class AdvancedGasInputs extends Component {
insufficientBalance: PropTypes.bool,
customPriceIsSafe: PropTypes.bool,
isSpeedUp: PropTypes.bool,
customGasLimitMessage: PropTypes.string,
}
constructor (props) {
@ -101,7 +102,7 @@ export default class AdvancedGasInputs extends Component {
return {}
}
renderGasInput ({ value, onChange, errorComponent, errorType, label, tooltipTitle }) {
renderGasInput ({ value, onChange, errorComponent, errorType, label, customMessageComponent, tooltipTitle }) {
return (
<div className="advanced-gas-inputs__gas-edit-row">
<div className="advanced-gas-inputs__gas-edit-row__label">
@ -140,7 +141,7 @@ export default class AdvancedGasInputs extends Component {
<i className="fa fa-sm fa-angle-down" />
</div>
</div>
{ errorComponent }
{ errorComponent || customMessageComponent }
</div>
</div>
)
@ -151,6 +152,7 @@ export default class AdvancedGasInputs extends Component {
insufficientBalance,
customPriceIsSafe,
isSpeedUp,
customGasLimitMessage,
} = this.props
const {
gasPrice,
@ -177,6 +179,14 @@ export default class AdvancedGasInputs extends Component {
</div>
) : null
const gasLimitCustomMessageComponent = customGasLimitMessage
? (
<div className="advanced-gas-inputs__gas-edit-row__custom-text">
{ customGasLimitMessage }
</div>
)
: null
return (
<div className="advanced-gas-inputs__gas-edit-rows">
{ this.renderGasInput({
@ -193,6 +203,7 @@ export default class AdvancedGasInputs extends Component {
value: this.state.gasLimit,
onChange: this.onChangeGasLimit,
errorComponent: gasLimitErrorComponent,
customMessageComponent: gasLimitCustomMessageComponent,
errorType: gasLimitErrorType,
}) }
</div>

View File

@ -132,5 +132,9 @@
right: 10px;
color: $dusty-gray;
}
&__custom-text {
@include H7;
}
}
}

View File

@ -26,6 +26,7 @@ export default class AdvancedTabContent extends Component {
customPriceIsSafe: PropTypes.bool,
isSpeedUp: PropTypes.bool,
isEthereumNetwork: PropTypes.bool,
customGasLimitMessage: PropTypes.string,
}
renderDataSummary (transactionFee, timeRemaining) {
@ -65,6 +66,7 @@ export default class AdvancedTabContent extends Component {
isSpeedUp,
transactionFee,
isEthereumNetwork,
customGasLimitMessage,
} = this.props
return (
@ -80,6 +82,7 @@ export default class AdvancedTabContent extends Component {
insufficientBalance={insufficientBalance}
customPriceIsSafe={customPriceIsSafe}
isSpeedUp={isSpeedUp}
customGasLimitMessage={customGasLimitMessage}
/>
</div>
{ isEthereumNetwork

View File

@ -2,6 +2,10 @@ import React, { Component } from 'react'
import PropTypes from 'prop-types'
import PageContainer from '../../../ui/page-container'
import { Tabs, Tab } from '../../../ui/tabs'
import { calcGasTotal } from '../../../../pages/send/send.utils'
import {
sumHexWEIsToRenderableFiat,
} from '../../../../helpers/utils/conversions.util'
import AdvancedTabContent from './advanced-tab-content'
import BasicTabContent from './basic-tab-content'
@ -9,6 +13,7 @@ export default class GasModalPageContainer extends Component {
static contextTypes = {
t: PropTypes.func,
metricsEvent: PropTypes.func,
trackEvent: PropTypes.func,
}
static propTypes = {
@ -29,6 +34,7 @@ export default class GasModalPageContainer extends Component {
newTotalEth: PropTypes.string,
sendAmount: PropTypes.string,
transactionFee: PropTypes.string,
extraInfoRow: PropTypes.shape({ label: PropTypes.string, value: PropTypes.string }),
}),
onSubmit: PropTypes.func,
customModalGasPriceInHex: PropTypes.string,
@ -43,9 +49,16 @@ export default class GasModalPageContainer extends Component {
isRetry: PropTypes.bool,
disableSave: PropTypes.bool,
isEthereumNetwork: PropTypes.bool,
customGasLimitMessage: PropTypes.string,
customTotalSupplement: PropTypes.string,
isSwap: PropTypes.boolean,
value: PropTypes.string,
conversionRate: PropTypes.string,
}
state = {}
state = {
selectedTab: 'Basic',
}
componentDidMount () {
const promise = this.props.hideBasic
@ -84,6 +97,7 @@ export default class GasModalPageContainer extends Component {
transactionFee,
},
isEthereumNetwork,
customGasLimitMessage,
} = this.props
return (
@ -92,6 +106,7 @@ export default class GasModalPageContainer extends Component {
updateCustomGasLimit={updateCustomGasLimit}
customModalGasPriceInHex={customModalGasPriceInHex}
customModalGasLimitInHex={customModalGasLimitInHex}
customGasLimitMessage={customGasLimitMessage}
timeRemaining={currentTimeEstimate}
transactionFee={transactionFee}
gasChartProps={gasChartProps}
@ -105,7 +120,7 @@ export default class GasModalPageContainer extends Component {
)
}
renderInfoRows (newTotalFiat, newTotalEth, sendAmount, transactionFee) {
renderInfoRows (newTotalFiat, newTotalEth, sendAmount, transactionFee, extraInfoRow) {
return (
<div className="gas-modal-content__info-row-wrapper">
<div className="gas-modal-content__info-row">
@ -117,6 +132,12 @@ export default class GasModalPageContainer extends Component {
<span className="gas-modal-content__info-row__transaction-info__label">{this.context.t('transactionFee')}</span>
<span className="gas-modal-content__info-row__transaction-info__value">{transactionFee}</span>
</div>
{extraInfoRow && (
<div className="gas-modal-content__info-row__transaction-info">
<span className="gas-modal-content__info-row__transaction-info__label">{extraInfoRow.label}</span>
<span className="gas-modal-content__info-row__transaction-info__value">{extraInfoRow.value}</span>
</div>
)}
<div className="gas-modal-content__info-row__total-info">
<span className="gas-modal-content__info-row__total-info__label">{this.context.t('newTotal')}</span>
<span className="gas-modal-content__info-row__total-info__value">{newTotalEth}</span>
@ -138,6 +159,7 @@ export default class GasModalPageContainer extends Component {
newTotalEth,
sendAmount,
transactionFee,
extraInfoRow,
},
} = this.props
@ -157,12 +179,12 @@ export default class GasModalPageContainer extends Component {
}
return (
<Tabs>
<Tabs onTabClick={(tabName) => this.setState({ selectedTab: tabName })}>
{tabsToRender.map(({ name, content }, i) => (
<Tab name={name} key={`gas-modal-tab-${i}`}>
<div className="gas-modal-content">
{ content }
{ this.renderInfoRows(newTotalFiat, newTotalEth, sendAmount, transactionFee) }
{ this.renderInfoRows(newTotalFiat, newTotalEth, sendAmount, transactionFee, extraInfoRow) }
</div>
</Tab>
))}
@ -199,7 +221,26 @@ export default class GasModalPageContainer extends Component {
},
})
}
onSubmit(customModalGasLimitInHex, customModalGasPriceInHex)
if (this.props.isSwap) {
const newSwapGasTotal = calcGasTotal(customModalGasLimitInHex, customModalGasPriceInHex)
let speedSet = ''
if (this.state.selectedTab === 'Basic') {
const { gasButtonInfo } = this.props.gasPriceButtonGroupProps
const selectedGasButtonInfo = gasButtonInfo.find(({ priceInHexWei }) => priceInHexWei === customModalGasPriceInHex)
speedSet = selectedGasButtonInfo?.gasEstimateType || ''
}
this.context.trackEvent({
event: 'Gas Fees Changed',
category: 'swaps',
properties: {
speed_set: speedSet,
gas_mode: this.state.selectedTab,
gas_fees: sumHexWEIsToRenderableFiat([this.props.value, newSwapGasTotal, this.props.customTotalSupplement], 'usd', this.props.conversionRate)?.slice(1),
},
})
}
onSubmit(customModalGasLimitInHex, customModalGasPriceInHex, this.state.selectedTab, this.context.mixPanelTrack)
}}
submitText={this.context.t('save')}
headerCloseText={this.context.t('close')}

View File

@ -11,6 +11,7 @@ import {
updateSendAmount,
setGasTotal,
updateTransaction,
setSwapsTxGasParams,
} from '../../../../store/actions'
import {
setCustomGasPrice,
@ -47,13 +48,11 @@ import {
} from '../../../../selectors'
import {
formatCurrency,
} from '../../../../helpers/utils/confirm-tx.util'
import {
addHexWEIsToDec,
addHexes,
subtractHexWEIsToDec,
decEthToConvertedCurrency as ethTotalToConvertedCurrency,
hexWEIToDecGWEI,
getValueFromWeiHex,
sumHexWEIsToRenderableFiat,
} from '../../../../helpers/utils/conversions.util'
import { getRenderableTimeEstimate } from '../../../../helpers/utils/gas-time-estimates.util'
import {
@ -69,10 +68,17 @@ import GasModalPageContainer from './gas-modal-page-container.component'
const mapStateToProps = (state, ownProps) => {
const { currentNetworkTxList, send } = state.metamask
const { modalState: { props: modalProps } = {} } = state.appState.modal || {}
const { txData = {} } = modalProps || {}
const {
txData = {},
isSwap = false,
customGasLimitMessage = '',
customTotalSupplement = '',
extraInfoRow = null,
} = modalProps || {}
const { transaction = {} } = ownProps
const selectedTransaction = currentNetworkTxList.find(({ id }) => id === (transaction.id || txData.id))
const selectedTransaction = isSwap
? txData
: currentNetworkTxList.find(({ id }) => id === (transaction.id || txData.id))
const buttonDataLoading = getBasicGasEstimateLoadingStatus(state)
const gasEstimatesLoading = getGasEstimatesLoadingStatus(state)
const sendToken = getSendToken(state)
@ -95,8 +101,11 @@ const mapStateToProps = (state, ownProps) => {
const currentCurrency = getCurrentCurrency(state)
const conversionRate = getConversionRate(state)
const newTotalFiat = addHexWEIsToRenderableFiat(value, customGasTotal, currentCurrency, conversionRate)
const newTotalFiat = sumHexWEIsToRenderableFiat(
[value, customGasTotal, customTotalSupplement],
currentCurrency,
conversionRate,
)
const { hideBasic } = state.appState.modal.modalState.props
@ -114,9 +123,13 @@ const mapStateToProps = (state, ownProps) => {
const isSendTokenSet = Boolean(sendToken)
const newTotalEth = maxModeOn && !isSendTokenSet ? addHexWEIsToRenderableEth(balance, '0x0') : addHexWEIsToRenderableEth(value, customGasTotal)
const newTotalEth = maxModeOn && !isSendTokenSet
? sumHexWEIsToRenderableEth([balance, '0x0'])
: sumHexWEIsToRenderableEth([value, customGasTotal, customTotalSupplement])
const sendAmount = maxModeOn && !isSendTokenSet ? subtractHexWEIsFromRenderableEth(balance, customGasTotal) : addHexWEIsToRenderableEth(value, '0x0')
const sendAmount = maxModeOn && !isSendTokenSet
? subtractHexWEIsFromRenderableEth(balance, customGasTotal)
: sumHexWEIsToRenderableEth([value, '0x0'])
const insufficientBalance = maxModeOn ? false : !isBalanceSufficient({
amount: value,
@ -135,6 +148,7 @@ const mapStateToProps = (state, ownProps) => {
return {
hideBasic,
isConfirm: isConfirm(state),
isSwap,
customModalGasPriceInHex,
customModalGasLimitInHex,
customGasPrice,
@ -158,12 +172,19 @@ const mapStateToProps = (state, ownProps) => {
estimatedTimesMax: estimatedTimes[0],
},
infoRowProps: {
originalTotalFiat: addHexWEIsToRenderableFiat(value, customGasTotal, currentCurrency, conversionRate),
originalTotalEth: addHexWEIsToRenderableEth(value, customGasTotal),
originalTotalFiat: sumHexWEIsToRenderableFiat(
[value, customGasTotal, customTotalSupplement],
currentCurrency,
conversionRate,
),
originalTotalEth: sumHexWEIsToRenderableEth(
[value, customGasTotal, customTotalSupplement],
),
newTotalFiat: showFiat ? newTotalFiat : '',
newTotalEth,
transactionFee: addHexWEIsToRenderableEth('0x0', customGasTotal),
transactionFee: sumHexWEIsToRenderableEth(['0x0', customGasTotal]),
sendAmount,
extraInfoRow,
},
transaction: txData || transaction,
isSpeedUp: transaction.status === 'submitted',
@ -176,6 +197,10 @@ const mapStateToProps = (state, ownProps) => {
sendToken,
balance,
tokenBalance: getTokenBalance(state),
customGasLimitMessage,
conversionRate,
value,
customTotalSupplement,
}
}
@ -214,6 +239,9 @@ const mapDispatchToProps = (dispatch) => {
dispatch(updateSendErrors({ amount: null }))
dispatch(updateSendAmount(calcMaxAmount(maxAmountDataObject)))
},
updateSwapTxGas: (gasLimit, gasPrice) => {
dispatch(setSwapsTxGasParams(gasLimit, gasPrice))
},
}
}
@ -222,6 +250,7 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => {
gasPriceButtonGroupProps,
// eslint-disable-next-line no-shadow
isConfirm,
isSwap,
txId,
isSpeedUp,
isRetry,
@ -245,6 +274,7 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => {
cancelAndClose: dispatchCancelAndClose,
hideModal: dispatchHideModal,
setAmountToMax: dispatchSetAmountToMax,
updateSwapTxGas: dispatchUpdateSwapTxGas,
...otherDispatchProps
} = dispatchProps
@ -253,7 +283,10 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => {
...otherDispatchProps,
...ownProps,
onSubmit: (gasLimit, gasPrice) => {
if (isConfirm) {
if (isSwap) {
dispatchUpdateSwapTxGas(gasLimit, gasPrice)
dispatchHideModal()
} else if (isConfirm) {
const updatedTx = {
...transaction,
txParams: {
@ -296,7 +329,11 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => {
dispatchHideSidebar()
}
},
disableSave: insufficientBalance || (isSpeedUp && customGasPrice === 0) || customGasLimit < 21000,
disableSave: (
insufficientBalance ||
(isSpeedUp && customGasPrice === 0) ||
customGasLimit < 21000
),
}
}
@ -314,19 +351,15 @@ function calcCustomGasLimit (customGasLimitInHex) {
return parseInt(customGasLimitInHex, 16)
}
function addHexWEIsToRenderableEth (aHexWEI, bHexWEI) {
return formatETHFee(addHexWEIsToDec(aHexWEI, bHexWEI))
function sumHexWEIsToRenderableEth (hexWEIs) {
const hexWEIsSum = hexWEIs.filter((n) => n).reduce(addHexes)
return formatETHFee(getValueFromWeiHex({
value: hexWEIsSum,
toCurrency: 'ETH',
numberOfDecimals: 6,
}))
}
function subtractHexWEIsFromRenderableEth (aHexWEI, bHexWEI) {
return formatETHFee(subtractHexWEIsToDec(aHexWEI, bHexWEI))
}
function addHexWEIsToRenderableFiat (aHexWEI, bHexWEI, convertedCurrency, conversionRate) {
const ethTotal = ethTotalToConvertedCurrency(
addHexWEIsToDec(aHexWEI, bHexWEI),
convertedCurrency,
conversionRate,
)
return formatCurrency(ethTotal, convertedCurrency)
}

View File

@ -56,6 +56,7 @@ const mockInfoRowProps = {
newTotalEth: 'mockNewTotalEth',
sendAmount: 'mockSendAmount',
transactionFee: 'mockTransactionFee',
extraInfoRow: { label: 'mockLabel', value: 'mockValue' },
}
const GP = GasModalPageContainer.prototype
@ -183,8 +184,8 @@ describe('GasModalPageContainer Component', function () {
assert.equal(GP.renderInfoRows.callCount, 2)
assert.deepEqual(GP.renderInfoRows.getCall(0).args, ['mockNewTotalFiat', 'mockNewTotalEth', 'mockSendAmount', 'mockTransactionFee'])
assert.deepEqual(GP.renderInfoRows.getCall(1).args, ['mockNewTotalFiat', 'mockNewTotalEth', 'mockSendAmount', 'mockTransactionFee'])
assert.deepEqual(GP.renderInfoRows.getCall(0).args, ['mockNewTotalFiat', 'mockNewTotalEth', 'mockSendAmount', 'mockTransactionFee', { label: 'mockLabel', value: 'mockValue' }])
assert.deepEqual(GP.renderInfoRows.getCall(1).args, ['mockNewTotalFiat', 'mockNewTotalEth', 'mockSendAmount', 'mockTransactionFee', { label: 'mockLabel', value: 'mockValue' }])
})
it('should not render the basic tab if hideBasic is true', function () {

View File

@ -62,6 +62,7 @@ describe('gas-modal-page-container container', function () {
txData: {
id: 34,
},
extraInfoRow: { label: 'mockLabel', value: 'mockValue' },
},
},
},
@ -125,6 +126,9 @@ describe('gas-modal-page-container container', function () {
currentTimeEstimate: '~1 min 11 sec',
newTotalFiat: '637.41',
blockTime: 12,
conversionRate: 50,
customGasLimitMessage: '',
customTotalSupplement: '',
customModalGasLimitInHex: 'aaaaaaaa',
customModalGasPriceInHex: 'ffffffff',
customGasTotal: 'aaaaaaa955555556',
@ -144,6 +148,7 @@ describe('gas-modal-page-container container', function () {
gasEstimatesLoading: false,
hideBasic: true,
infoRowProps: {
extraInfoRow: { label: 'mockLabel', value: 'mockValue' },
originalTotalFiat: '637.41',
originalTotalEth: '12.748189 ETH',
newTotalFiat: '637.41',
@ -154,6 +159,7 @@ describe('gas-modal-page-container container', function () {
insufficientBalance: true,
isSpeedUp: false,
isRetry: false,
isSwap: false,
txId: 34,
isEthereumNetwork: true,
isMainnet: true,
@ -163,6 +169,7 @@ describe('gas-modal-page-container container', function () {
transaction: {
id: 34,
},
value: '0x640000000000000',
}
const baseMockOwnProps = { transaction: { id: 34 } }
const tests = [

View File

@ -21,6 +21,7 @@ export default class EditApprovalPermission extends PureComponent {
tokenBalance: PropTypes.string,
setCustomAmount: PropTypes.func,
origin: PropTypes.string.isRequired,
requiredMinimum: PropTypes.instanceOf(BigNumber),
}
static contextTypes = {
@ -28,7 +29,8 @@ export default class EditApprovalPermission extends PureComponent {
}
state = {
customSpendLimit: this.props.customTokenAmount,
// This is used as a TextField value, which should be a string.
customSpendLimit: this.props.customTokenAmount || '',
selectedOptionIsUnlimited: !this.props.customTokenAmount,
}
@ -63,8 +65,10 @@ export default class EditApprovalPermission extends PureComponent {
address={address}
diameter={32}
/>
<div className="edit-approval-permission__account-info__name">{ name }</div>
<div>{ t('balance') }</div>
<div className="edit-approval-permission__name-and-balance-container">
<div className="edit-approval-permission__account-info__name">{ name }</div>
<div>{ t('balance') }</div>
</div>
</div>
<div className="edit-approval-permission__account-info__balance">
{`${Number(tokenBalance).toPrecision(9)} ${tokenSymbol}`}
@ -163,7 +167,7 @@ export default class EditApprovalPermission extends PureComponent {
validateSpendLimit () {
const { t } = this.context
const { decimals } = this.props
const { decimals, requiredMinimum } = this.props
const { selectedOptionIsUnlimited, customSpendLimit } = this.state
if (selectedOptionIsUnlimited || !customSpendLimit) {
@ -187,6 +191,13 @@ export default class EditApprovalPermission extends PureComponent {
return t('spendLimitTooLarge')
}
if (
requiredMinimum !== undefined &&
customSpendLimitNumber.lessThan(requiredMinimum)
) {
return t('spendLimitInsufficient')
}
return undefined
}

View File

@ -46,12 +46,13 @@
}
&__name {
margin-left: 8px;
margin-right: 8px;
min-width: 64px;
}
&__balance {
color: #6a737d;
margin-left: 8px;
}
}
@ -155,6 +156,15 @@
position: absolute;
}
}
&__name-and-balance-container {
display: flex;
flex: 0 0 100%;
flex-flow: column;
align-items: flex-start;
justify-content: center;
margin-left: 8px;
}
}
.edit-approval-permission-modal-content {

View File

@ -5,12 +5,14 @@ import Interaction from '../../ui/icon/interaction-icon.component'
import Receive from '../../ui/icon/receive-icon.component'
import Send from '../../ui/icon/send-icon.component'
import Sign from '../../ui/icon/sign-icon.component'
import Swap from '../../ui/icon/swap-icon-for-list.component'
import {
TRANSACTION_CATEGORY_APPROVAL,
TRANSACTION_CATEGORY_SIGNATURE_REQUEST,
TRANSACTION_CATEGORY_INTERACTION,
TRANSACTION_CATEGORY_SEND,
TRANSACTION_CATEGORY_RECEIVE,
TRANSACTION_CATEGORY_SWAP,
UNAPPROVED_STATUS,
FAILED_STATUS,
REJECTED_STATUS,
@ -26,6 +28,7 @@ const ICON_MAP = {
[TRANSACTION_CATEGORY_SEND]: Send,
[TRANSACTION_CATEGORY_SIGNATURE_REQUEST]: Sign,
[TRANSACTION_CATEGORY_RECEIVE]: Receive,
[TRANSACTION_CATEGORY_SWAP]: Swap,
}
const FAIL_COLOR = '#D73A49'

View File

@ -12,19 +12,36 @@ import * as actions from '../../../ducks/gas/gas.duck'
import { useI18nContext } from '../../../hooks/useI18nContext'
import TransactionListItem from '../transaction-list-item'
import Button from '../../ui/button'
import { TOKEN_CATEGORY_HASH } from '../../../helpers/constants/transactions'
import { TOKEN_CATEGORY_HASH, TRANSACTION_CATEGORY_SWAP } from '../../../helpers/constants/transactions'
import { SWAPS_CONTRACT_ADDRESS } from '../../../helpers/constants/swaps'
const PAGE_INCREMENT = 10
const getTransactionGroupRecipientAddressFilter = (recipientAddress) => {
return ({ initialTransaction: { txParams } }) => txParams && txParams.to === recipientAddress
return ({ initialTransaction: { txParams } }) => {
return txParams?.to === recipientAddress || (
txParams?.to === SWAPS_CONTRACT_ADDRESS &&
txParams.data.match(recipientAddress.slice(2))
)
}
}
const tokenTransactionFilter = ({
initialTransaction: {
transactionCategory,
},
}) => !TOKEN_CATEGORY_HASH[transactionCategory]
primaryTransaction: {
destinationTokenSymbol,
sourceTokenSymbol,
},
}) => {
if (TOKEN_CATEGORY_HASH[transactionCategory]) {
return false
} else if (transactionCategory === TRANSACTION_CATEGORY_SWAP) {
return destinationTokenSymbol === 'ETH' || sourceTokenSymbol === 'ETH'
}
return true
}
const getFilteredTransactionGroups = (transactionGroups, hideTokenTransactions, tokenAddress) => {
if (hideTokenTransactions) {
@ -88,7 +105,11 @@ export default function TransactionList ({ hideTokenTransactions, tokenAddress }
</div>
{
pendingTransactions.map((transactionGroup, index) => (
<TransactionListItem isEarliestNonce={index === 0} transactionGroup={transactionGroup} key={`${transactionGroup.nonce}:${index}`} />
<TransactionListItem
isEarliestNonce={index === 0}
transactionGroup={transactionGroup}
key={`${transactionGroup.nonce}:${index}`}
/>
))
}
</div>
@ -107,7 +128,10 @@ export default function TransactionList ({ hideTokenTransactions, tokenAddress }
{
completedTransactions.length > 0
? completedTransactions.slice(0, limit).map((transactionGroup, index) => (
<TransactionListItem transactionGroup={transactionGroup} key={`${transactionGroup.nonce}:${limit + index - 10}`} />
<TransactionListItem
transactionGroup={transactionGroup}
key={`${transactionGroup.nonce}:${limit + index - 10}`}
/>
))
: (
<div className="transaction-list__empty">

View File

@ -4,17 +4,22 @@ import { useDispatch, useSelector } from 'react-redux'
import classnames from 'classnames'
import { useHistory } from 'react-router-dom'
import Button from '../../ui/button'
import Identicon from '../../ui/identicon'
import { I18nContext } from '../../../contexts/i18n'
import { SEND_ROUTE } from '../../../helpers/constants/routes'
import { useMetricEvent } from '../../../hooks/useMetricEvent'
import { SEND_ROUTE, BUILD_QUOTE_ROUTE } from '../../../helpers/constants/routes'
import { useMetricEvent, useNewMetricEvent } from '../../../hooks/useMetricEvent'
import Tooltip from '../../ui/tooltip'
import UserPreferencedCurrencyDisplay from '../user-preferenced-currency-display'
import { PRIMARY, SECONDARY } from '../../../helpers/constants/common'
import { showModal } from '../../../store/actions'
import { isBalanceCached, getSelectedAccount, getShouldShowFiat } from '../../../selectors/selectors'
import PaperAirplane from '../../ui/icon/paper-airplane-icon'
import { isBalanceCached, getSelectedAccount, getShouldShowFiat, getCurrentNetworkId, getCurrentKeyring } from '../../../selectors/selectors'
import { getValueFromWeiHex } from '../../../helpers/utils/conversions.util'
import SwapIcon from '../../ui/icon/swap-icon.component'
import BuyIcon from '../../ui/icon/overview-buy-icon.component'
import SendIcon from '../../ui/icon/overview-send-icon.component'
import { getSwapsFeatureLiveness, setSwapsFromToken } from '../../../ducks/swaps/swaps'
import { ETH_SWAPS_TOKEN_OBJECT } from '../../../helpers/constants/swaps'
import IconButton from '../../ui/icon-button'
import WalletOverview from './wallet-overview'
const EthOverview = ({ className }) => {
@ -35,10 +40,15 @@ const EthOverview = ({ className }) => {
},
})
const history = useHistory()
const keyring = useSelector(getCurrentKeyring)
const usingHardwareWallet = keyring.type.search('Hardware') !== -1
const balanceIsCached = useSelector(isBalanceCached)
const showFiat = useSelector(getShouldShowFiat)
const selectedAccount = useSelector(getSelectedAccount)
const { balance } = selectedAccount
const networkId = useSelector(getCurrentNetworkId)
const enteredSwapsEvent = useNewMetricEvent({ event: 'Swaps Opened', properties: { source: 'Main View', active_currency: 'ETH' }, category: 'swaps' })
const swapsEnabled = useSelector(getSwapsFeatureLiveness)
return (
<WalletOverview
@ -80,30 +90,53 @@ const EthOverview = ({ className }) => {
)}
buttons={(
<>
<Button
type="primary"
<IconButton
className="eth-overview__button"
rounded
Icon={BuyIcon}
label={t('buy')}
onClick={() => {
depositEvent()
dispatch(showModal({ name: 'DEPOSIT_ETHER' }))
}}
>
{ t('buy') }
</Button>
<Button
type="secondary"
/>
<IconButton
className="eth-overview__button"
rounded
icon={<PaperAirplane color="#037DD6" size={20} />}
data-testid="eth-overview-send"
Icon={SendIcon}
label={t('send')}
onClick={() => {
sendEvent()
history.push(SEND_ROUTE)
}}
data-testid="eth-overview-send"
>
{ t('send') }
</Button>
/>
{swapsEnabled ? (
<IconButton
className="eth-overview__button"
disabled={networkId !== '1'}
Icon={SwapIcon}
onClick={() => {
if (networkId === '1') {
enteredSwapsEvent()
dispatch(setSwapsFromToken({
...ETH_SWAPS_TOKEN_OBJECT,
balance,
string: getValueFromWeiHex({ value: balance, numberOfDecimals: 4, toDenomination: 'ETH' }),
}))
if (usingHardwareWallet) {
global.platform.openExtensionInBrowser(BUILD_QUOTE_ROUTE)
} else {
history.push(BUILD_QUOTE_ROUTE)
}
}
}}
label={ t('swap') }
tooltipRender={(contents) => (
<Tooltip title={t('onlyAvailableOnMainnet')} position="bottom" disabled={networkId === '1'}>
{contents}
</Tooltip>
)}
/>
) : null}
</>
)}
className={className}

View File

@ -3,7 +3,7 @@
justify-content: space-between;
align-items: center;
flex: 1;
height: 209px;
min-height: 209px;
min-width: 0;
padding-top: 10px;
flex-direction: column;
@ -20,7 +20,7 @@
&__buttons {
display: flex;
flex-direction: row;
height: 44px;
height: 68px;
margin-bottom: 24px;
}
}
@ -83,8 +83,23 @@
color: $Grey-400;
}
.wallet-overview &__button {
@extend %asset-buttons;
&__button {
margin-right: 24px;
}
&__button:last-of-type {
margin-right: 0;
}
&__circle {
display: flex;
justify-content: center;
align-items: center;
height: 36px;
width: 36px;
background: #037dd6;
border-radius: 18px;
margin-top: 6px;
}
}
@ -115,7 +130,11 @@
color: $Grey-400;
}
.wallet-overview &__button {
@extend %asset-buttons;
&__button {
margin-right: 24px;
}
&__button:last-of-type {
margin-right: 0;
}
}

View File

@ -3,17 +3,22 @@ import PropTypes from 'prop-types'
import { useDispatch, useSelector } from 'react-redux'
import { useHistory } from 'react-router-dom'
import Button from '../../ui/button'
import Identicon from '../../ui/identicon'
import Tooltip from '../../ui/tooltip'
import CurrencyDisplay from '../../ui/currency-display'
import { I18nContext } from '../../../contexts/i18n'
import { SEND_ROUTE } from '../../../helpers/constants/routes'
import { useMetricEvent } from '../../../hooks/useMetricEvent'
import { SEND_ROUTE, BUILD_QUOTE_ROUTE } from '../../../helpers/constants/routes'
import { useMetricEvent, useNewMetricEvent } from '../../../hooks/useMetricEvent'
import { useTokenTracker } from '../../../hooks/useTokenTracker'
import { useTokenFiatAmount } from '../../../hooks/useTokenFiatAmount'
import { getAssetImages } from '../../../selectors/selectors'
import { updateSendToken } from '../../../store/actions'
import PaperAirplane from '../../ui/icon/paper-airplane-icon'
import { getSwapsFeatureLiveness, setSwapsFromToken } from '../../../ducks/swaps/swaps'
import { getAssetImages, getCurrentKeyring, getCurrentNetworkId } from '../../../selectors/selectors'
import SwapIcon from '../../ui/icon/swap-icon.component'
import SendIcon from '../../ui/icon/overview-send-icon.component'
import IconButton from '../../ui/icon-button'
import WalletOverview from './wallet-overview'
const TokenOverview = ({ className, token }) => {
@ -28,9 +33,15 @@ const TokenOverview = ({ className, token }) => {
})
const history = useHistory()
const assetImages = useSelector(getAssetImages)
const keyring = useSelector(getCurrentKeyring)
const usingHardwareWallet = keyring.type.search('Hardware') !== -1
const { tokensWithBalances } = useTokenTracker([token])
const balance = tokensWithBalances[0]?.string
const formattedFiatBalance = useTokenFiatAmount(token.address, balance, token.symbol)
const networkId = useSelector(getCurrentNetworkId)
const enteredSwapsEvent = useNewMetricEvent({ event: 'Swaps Opened', properties: { source: 'Token View', active_currency: token.symbol }, category: 'swaps' })
const swapsEnabled = useSelector(getSwapsFeatureLiveness)
return (
<WalletOverview
@ -55,19 +66,43 @@ const TokenOverview = ({ className, token }) => {
</div>
)}
buttons={(
<Button
type="secondary"
className="token-overview__button"
rounded
icon={<PaperAirplane color="#037DD6" size={20} />}
onClick={() => {
sendTokenEvent()
dispatch(updateSendToken(token))
history.push(SEND_ROUTE)
}}
>
{ t('send') }
</Button>
<>
<IconButton
className="token-overview__button"
onClick={() => {
sendTokenEvent()
dispatch(updateSendToken(token))
history.push(SEND_ROUTE)
}}
Icon={SendIcon}
label={t('send')}
data-testid="eth-overview-send"
/>
{swapsEnabled ? (
<IconButton
className="token-overview__button"
disabled={networkId !== '1'}
Icon={SwapIcon}
onClick={() => {
if (networkId === '1') {
enteredSwapsEvent()
dispatch(setSwapsFromToken({ ...token, iconUrl: assetImages[token.address] }))
if (usingHardwareWallet) {
global.platform.openExtensionInBrowser(BUILD_QUOTE_ROUTE)
} else {
history.push(BUILD_QUOTE_ROUTE)
}
}
}}
label={ t('swap') }
tooltipRender={(contents) => (
<Tooltip title={t('onlyAvailableOnMainnet')} position="bottom" disabled={networkId === '1'}>
{contents}
</Tooltip>
)}
/>
) : null}
</>
)}
className={className}
icon={(

View File

@ -50,9 +50,9 @@
padding: 0;
&--active {
font-weight: bold;
background: $Blue-300;
background: $Blue-500;
color: white;
border: none;
}
&--danger {
@ -61,7 +61,6 @@
background: $white;
}
&:hover {
box-shadow: 0 0 14px rgba(0, 0, 0, 0.18);
};

View File

@ -0,0 +1,37 @@
import React from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
const defaultRender = (inner) => inner
export default function IconButton ({ onClick, Icon, disabled, label, tooltipRender, className, ...props }) {
const renderWrapper = tooltipRender ?? defaultRender
return (
<button
className={classNames('icon-button', className, { 'icon-button--disabled': disabled })}
data-testid={props['data-testid'] ?? undefined}
onClick={onClick}
>
{renderWrapper(
<>
<div
className="icon-button__circle"
>
<Icon />
</div>
<span>{label}</span>
</>,
)}
</button>
)
}
IconButton.propTypes = {
onClick: PropTypes.func.isRequired,
Icon: PropTypes.element.isRequired,
disabled: PropTypes.bool,
label: PropTypes.string.isRequired,
tooltipRender: PropTypes.func,
className: PropTypes.string,
'data-testid': PropTypes.string,
}

View File

@ -0,0 +1,30 @@
.icon-button {
display: flex;
flex-direction: column;
align-items: center;
background-color: unset;
text-align: center;
@include H7;
font-size: 13px;
cursor: pointer;
color: $Blue-500;
&__circle {
display: flex;
justify-content: center;
align-items: center;
height: 36px;
width: 36px;
background: #037dd6;
border-radius: 18px;
margin-top: 6px;
margin-bottom: 5px;
}
&--disabled {
opacity: 0.3;
cursor: auto;
}
}

View File

@ -0,0 +1 @@
export { default } from './icon-button'

View File

@ -1,11 +1,14 @@
import React, { PureComponent } from 'react'
import PropTypes from 'prop-types'
import classnames from 'classnames'
export default class IconWithFallback extends PureComponent {
static propTypes = {
icon: PropTypes.string,
name: PropTypes.string,
size: PropTypes.number.isRequired,
size: PropTypes.number,
className: PropTypes.string,
fallbackClassName: PropTypes.string,
}
static defaultProps = {
@ -18,8 +21,8 @@ export default class IconWithFallback extends PureComponent {
}
render () {
const { icon, name, size } = this.props
const style = { height: `${size}px`, width: `${size}px` }
const { icon, name, size, className, fallbackClassName } = this.props
const style = size ? { height: `${size}px`, width: `${size}px` } : {}
return !this.state.iconError && icon
? (
@ -27,10 +30,11 @@ export default class IconWithFallback extends PureComponent {
onError={() => this.setState({ iconError: true })}
src={icon}
style={style}
className={className}
/>
)
: (
<i className="icon-with-fallback__fallback">
<i className={classnames('icon-with-fallback__fallback', fallbackClassName)}>
{ name.length ? name.charAt(0).toUpperCase() : '' }
</i>
)

View File

@ -0,0 +1,25 @@
import React from 'react'
import PropTypes from 'prop-types'
export default function BuyIcon ({
width = '17',
height = '21',
fill = 'white',
}) {
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M8.62829 14.3216C8.65369 14.2947 8.67756 14.2664 8.69978 14.2368L12.8311 10.1286C13.0886 9.87975 13.1913 9.51233 13.1 9.16703C13.0087 8.82174 12.7375 8.55207 12.3903 8.46129C12.0431 8.37051 11.6736 8.47268 11.4233 8.72869L8.89913 11.2387L8.89913 1.3293C8.90647 0.970874 8.71837 0.636511 8.40739 0.455161C8.0964 0.273811 7.71112 0.27381 7.40014 0.45516C7.08915 0.636511 6.90105 0.970873 6.90839 1.3293L6.90839 11.2387L4.38422 8.72869C4.13396 8.47268 3.76446 8.37051 3.41722 8.46129C3.06998 8.55207 2.79879 8.82174 2.7075 9.16703C2.61621 9.51233 2.71896 9.87975 2.97641 10.1286L7.11049 14.2395C7.28724 14.4717 7.55784 14.6148 7.85026 14.6306C8.14268 14.6464 8.42727 14.5333 8.62829 14.3216Z"
fill={fill}
/>
<rect x="0.260986" y="15.75" width="15.8387" height="2.25" rx="1" fill="white" />
</svg>
)
}
BuyIcon.propTypes = {
width: PropTypes.string,
height: PropTypes.string,
fill: PropTypes.string,
}

View File

@ -0,0 +1,23 @@
import React from 'react'
import PropTypes from 'prop-types'
export default function SwapIcon ({
width = '15',
height = '15',
fill = 'white',
}) {
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.6827 0.889329C13.6458 0.890495 13.609 0.893722 13.5725 0.898996H7.76263C7.40564 0.893947 7.07358 1.08151 6.89361 1.38986C6.71364 1.69821 6.71364 2.07958 6.89361 2.38793C7.07358 2.69628 7.40564 2.88384 7.76263 2.87879H11.3124L1.12335 13.0678C0.864749 13.3161 0.760577 13.6848 0.851011 14.0315C0.941446 14.3786 1.21235 14.6495 1.55926 14.7399C1.90616 14.8303 2.27485 14.7262 2.52313 14.4676L12.7121 4.27857V7.82829C12.7071 8.18528 12.8946 8.51734 13.203 8.69731C13.5113 8.87728 13.8927 8.87728 14.2011 8.69731C14.5094 8.51734 14.697 8.18528 14.6919 7.82829V2.01457C14.7318 1.7261 14.6427 1.43469 14.4483 1.2179C14.2538 1.00111 13.9738 0.880924 13.6827 0.889329Z"
fill={fill}
/>
</svg>
)
}
SwapIcon.propTypes = {
width: PropTypes.string,
height: PropTypes.string,
fill: PropTypes.string,
}

View File

@ -0,0 +1,23 @@
import React from 'react'
import PropTypes from 'prop-types'
export default function SunCheck ({ reverseColors }) {
const sunColor = reverseColors ? '#037DD6' : 'white'
const checkColor = reverseColors ? 'white' : '#037DD6'
return (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.2148 9.05384C13.432 8.40203 14.8878 7.92403 14.8878 7.20703C14.8878 6.49003 13.432 6.01204 13.2148 5.36022C12.9975 4.68668 13.8883 3.44823 13.4755 2.88332C13.0627 2.31842 11.607 2.77469 11.0421 2.3836C10.4771 1.97078 10.4771 0.449879 9.80361 0.232608C9.15179 0.0153358 8.26098 1.25378 7.54398 1.25378C6.82698 1.25378 5.91444 0.0153358 5.28435 0.232608C4.61081 0.449879 4.61081 1.99251 4.04591 2.3836C3.481 2.79641 2.02528 2.31842 1.61246 2.88332C1.19965 3.44823 2.09046 4.68668 1.87319 5.36022C1.65592 6.01204 0.200195 6.49003 0.200195 7.20703C0.200195 7.92403 1.65592 8.40203 1.87319 9.05384C2.09046 9.72738 1.19965 10.9658 1.61246 11.5307C2.02528 12.0956 3.481 11.6394 4.04591 12.0305C4.61081 12.4433 4.61081 13.9642 5.28435 14.1815C5.93617 14.3987 6.82698 13.1603 7.54398 13.1603C8.26098 13.1603 9.17352 14.3987 9.80361 14.1815C10.4771 13.9642 10.4771 12.4216 11.0421 12.0305C11.607 11.6176 13.0627 12.0956 13.4755 11.5307C13.8883 10.9658 12.9975 9.70566 13.2148 9.05384Z"
fill={sunColor}
/>
<path
d="M6.42285 10.084L4.13965 7.81445C4.07585 7.75065 4.04395 7.66862 4.04395 7.56836C4.04395 7.4681 4.07585 7.38607 4.13965 7.32227L4.64551 6.83008C4.70931 6.75716 4.78678 6.7207 4.87793 6.7207C4.97819 6.7207 5.06478 6.75716 5.1377 6.83008L6.66895 8.36133L9.9502 5.08008C10.0231 5.00716 10.1051 4.9707 10.1963 4.9707C10.2965 4.9707 10.3786 5.00716 10.4424 5.08008L10.9482 5.57227C11.012 5.63607 11.0439 5.7181 11.0439 5.81836C11.0439 5.91862 11.012 6.00065 10.9482 6.06445L6.91504 10.084C6.85124 10.1569 6.76921 10.1934 6.66895 10.1934C6.56868 10.1934 6.48665 10.1569 6.42285 10.084Z"
fill={checkColor}
/>
</svg>
)
}
SunCheck.propTypes = {
reverseColors: PropTypes.bool,
}

View File

@ -0,0 +1,25 @@
import React from 'react'
import PropTypes from 'prop-types'
const Swap = ({
className,
size,
color,
}) => (
<svg width={size} height={size} viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
<path fillRule="evenodd" clipRule="evenodd" d="M17 33C25.8366 33 33 25.8366 33 17C33 8.16344 25.8366 1 17 1C8.16344 1 1 8.16344 1 17C1 25.8366 8.16344 33 17 33Z" stroke={color} />
<path d="M21.4444 21.2214C21.4444 21.4147 21.2877 21.5714 21.0944 21.5714H12.9056C12.7123 21.5714 12.5556 21.4147 12.5556 21.2214V19.6907C12.5556 19.3765 12.1736 19.2214 11.9546 19.4467L9.2372 22.2417C9.10513 22.3776 9.10513 22.5938 9.2372 22.7297L11.9546 25.5247C12.1736 25.75 12.5556 25.595 12.5556 25.2808V23.75C12.5556 23.5567 12.7123 23.4 12.9056 23.4H22.8722C23.0655 23.4 23.2222 23.2433 23.2222 23.05V18.2643C23.2222 18.071 23.0655 17.9143 22.8722 17.9143H21.7944C21.6011 17.9143 21.4444 18.071 21.4444 18.2643V21.2214ZM12.5556 13.9214C12.5556 13.7281 12.7123 13.5714 12.9056 13.5714H21.0944C21.2877 13.5714 21.4444 13.7281 21.4444 13.9214V15.4522C21.4444 15.7664 21.8264 15.9214 22.0454 15.6962L24.7628 12.9011C24.8949 12.7653 24.8949 12.549 24.7628 12.4132L22.0454 9.61812C21.8264 9.39284 21.4444 9.5479 21.4444 9.8621V11.3929C21.4444 11.5862 21.2877 11.7429 21.0944 11.7429H11.1278C10.9345 11.7429 10.7778 11.8996 10.7778 12.0929V16.8786C10.7778 17.0719 10.9345 17.2286 11.1278 17.2286H12.2056C12.3989 17.2286 12.5556 17.0719 12.5556 16.8786V13.9214Z" fill={color} />
</svg>
)
Swap.defaultProps = {
className: undefined,
}
Swap.propTypes = {
className: PropTypes.string,
size: PropTypes.number.isRequired,
color: PropTypes.string.isRequired,
}
export default Swap

View File

@ -0,0 +1,25 @@
import React from 'react'
import PropTypes from 'prop-types'
export default function SwapIcon ({
width = '17',
height = '17',
color = 'white',
}) {
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.1714 9.66035V12.3786H4.68253C4.51685 12.3786 4.38253 12.2443 4.38253 12.0786V10.5478C4.38253 10.1888 3.94605 10.0116 3.69574 10.269L0.978328 13.0641C0.827392 13.2193 0.827392 13.4665 0.978328 13.6217L3.69573 16.4168C3.94604 16.6742 4.38253 16.497 4.38253 16.1379V14.6072C4.38253 14.4415 4.51685 14.3072 4.68253 14.3072H14.9992H15.0492V14.2572V9.66035C15.0492 9.14182 14.6288 8.72146 14.1103 8.72146C13.5918 8.72146 13.1714 9.14182 13.1714 9.66035ZM2.55476 2.55003H2.50476V2.60003V7.19686C2.50476 7.71539 2.92511 8.13575 3.44364 8.13575C3.96218 8.13575 4.38253 7.71539 4.38253 7.19686V4.70619C4.38253 4.5805 4.48443 4.47861 4.61012 4.47861H12.8714C13.0371 4.47861 13.1714 4.61292 13.1714 4.77861V6.30937C13.1714 6.66845 13.6079 6.84566 13.8582 6.5882L16.5756 3.79315C16.7266 3.6379 16.7266 3.39074 16.5756 3.23549L13.8582 0.440443C13.6079 0.182981 13.1714 0.360188 13.1714 0.719273V2.25004C13.1714 2.41572 13.0371 2.55003 12.8714 2.55003H2.55476Z"
fill={color}
stroke={color}
strokeWidth="0.1"
/>
</svg>
)
}
SwapIcon.propTypes = {
width: PropTypes.string,
height: PropTypes.string,
color: PropTypes.string,
}

View File

@ -1,7 +1,7 @@
.info-tooltip {
img {
height: 10px;
width: 10px;
height: 12px;
width: 12px;
}
}
@ -32,7 +32,7 @@
padding-bottom: 15px;
.tippy-tooltip-content {
@include H8;
@include H7;
text-align: left;
color: $Grey-500;

View File

@ -1,5 +1,6 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import classnames from 'classnames'
import Button from '../../button'
export default class PageContainerFooter extends Component {
@ -15,6 +16,8 @@ export default class PageContainerFooter extends Component {
submitButtonType: PropTypes.string,
hideCancel: PropTypes.bool,
buttonSizeLarge: PropTypes.bool,
footerClassName: PropTypes.string,
footerButtonClassName: PropTypes.string,
}
static contextTypes = {
@ -33,17 +36,19 @@ export default class PageContainerFooter extends Component {
hideCancel,
cancelButtonType,
buttonSizeLarge = false,
footerClassName,
footerButtonClassName,
} = this.props
return (
<div className="page-container__footer">
<div className={classnames('page-container__footer', footerClassName)}>
<footer>
{!hideCancel && (
<Button
type={cancelButtonType || 'default'}
large={buttonSizeLarge}
className="page-container__footer-button"
className={classnames('page-container__footer-button', footerButtonClassName)}
onClick={(e) => onCancel(e)}
data-testid="page-container-footer-cancel"
>
@ -54,7 +59,7 @@ export default class PageContainerFooter extends Component {
<Button
type={submitButtonType || 'secondary'}
large={buttonSizeLarge}
className="page-container__footer-button"
className={classnames('page-container__footer-button', footerButtonClassName)}
disabled={disabled}
onClick={(e) => onSubmit(e)}
data-testid="page-container-footer-next"

View File

@ -5,8 +5,8 @@
&__loading-dot-two,
&__loading-dot-three {
background: $Blue-500;
width: 4px;
height: 4px;
width: 9px;
height: 9px;
margin-right: 2px;
border-radius: 100%;
animation-fill-mode: both;

View File

@ -0,0 +1,30 @@
.tippy-tooltip.white-theme {
background: white;
color: black;
box-shadow: 0 0 14px rgba(0, 0, 0, 0.18);
padding: 12px 16px;
padding-bottom: 11px;
.tippy-tooltip-content {
@include H7;
text-align: left;
color: $Grey-500;
}
}
.tippy-popper[x-placement^=top] .white-theme [x-arrow] {
border-top-color: $white;
}
.tippy-popper[x-placement^=right] .white-theme [x-arrow] {
border-right-color: $white;
}
.tippy-popper[x-placement^=left] .white-theme [x-arrow] {
border-left-color: $white;
}
.tippy-popper[x-placement^=bottom] .white-theme [x-arrow] {
border-bottom-color: $white;
}

View File

@ -16,6 +16,7 @@
@import 'error-message/index';
@import 'export-text-container/index';
@import 'icon-border/icon-border';
@import 'icon-button/icon-button';
@import 'icon-with-fallback/icon-with-fallback';
@import 'icon-with-label/index';
@import 'icon/index';
@ -35,4 +36,6 @@
@import 'tabs/index';
@import 'toggle-button/index';
@import 'token-balance/index';
@import 'tooltip/index';
@import 'unit-input/index';
@import 'url-icon/index';

View File

@ -0,0 +1 @@
export { default } from './url-icon'

View File

@ -0,0 +1,38 @@
.url-icon {
width: 24px;
height: 24px;
background-position: center;
border-radius: 50%;
background-color: $white;
box-shadow: 0 2px 4px 0 rgba($black, 0.24);
flex: 0 0 auto;
-moz-animation: fadein 1s;
/* Firefox */
-webkit-animation: fadein 1s;
/* Safari and Chrome */
-o-animation: fadein 1s;
&__fallback {
width: 24px;
height: 24px;
border-radius: 50%;
background: #bbc0c5;
flex: 0 1 auto;
justify-content: center;
align-items: center;
text-align: center;
padding-top: 2px;
}
}
@keyframes fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

View File

@ -0,0 +1,25 @@
import React from 'react'
import PropTypes from 'prop-types'
import classnames from 'classnames'
import IconWithFallback from '../icon-with-fallback'
export default function UrlIcon ({
url,
className,
name,
}) {
return (
<IconWithFallback
className={classnames('url-icon', className)}
icon={url}
name={name}
fallbackClassName="url-icon__fallback"
/>
)
}
UrlIcon.propTypes = {
url: PropTypes.string,
className: PropTypes.string,
name: PropTypes.string,
}

View File

@ -7,6 +7,7 @@ import appStateReducer from './app/app'
import confirmTransactionReducer from './confirm-transaction/confirm-transaction.duck'
import gasReducer from './gas/gas.duck'
import { invalidCustomNetwork, unconnectedAccount } from './alerts'
import swapsReducer from './swaps/swaps'
import historyReducer from './history/history'
export default combineReducers({
@ -18,6 +19,7 @@ export default combineReducers({
history: historyReducer,
send: sendReducer,
confirmTransaction: confirmTransactionReducer,
swaps: swapsReducer,
gas: gasReducer,
localeMessages: localeMessagesReducer,
})

570
ui/app/ducks/swaps/swaps.js Normal file
View File

@ -0,0 +1,570 @@
import { createSlice } from '@reduxjs/toolkit'
import BigNumber from 'bignumber.js'
import log from 'loglevel'
import {
addToken,
addUnapprovedTransaction,
fetchAndSetQuotes,
forceUpdateMetamaskState,
resetSwapsPostFetchState,
setBackgroundSwapRouteState,
setInitialGasEstimate,
setSwapsErrorKey,
setSwapsTxGasPrice,
setApproveTxId,
setTradeTxId,
stopPollingForQuotes,
updateAndApproveTx,
updateTransaction,
resetBackgroundSwapsState,
setSwapsLiveness,
} from '../../store/actions'
import { AWAITING_SWAP_ROUTE, BUILD_QUOTE_ROUTE, LOADING_QUOTES_ROUTE, SWAPS_ERROR_ROUTE, SWAPS_MAINTENANCE_ROUTE } from '../../helpers/constants/routes'
import { fetchTradesInfo, fetchSwapsFeatureLiveness } from '../../pages/swaps/swaps.util'
import { calcGasTotal } from '../../pages/send/send.utils'
import { decimalToHex, getValueFromWeiHex, hexMax, decGWEIToHexWEI, hexWEIToDecETH } from '../../helpers/utils/conversions.util'
import { constructTxParams } from '../../helpers/utils/util'
import { calcTokenAmount } from '../../helpers/utils/token-util'
import {
getFastPriceEstimateInHexWEI,
getSelectedAccount,
getTokenExchangeRates,
conversionRateSelector as getConversionRate,
} from '../../selectors'
import {
ERROR_FETCHING_QUOTES,
QUOTES_NOT_AVAILABLE_ERROR,
ETH_SWAPS_TOKEN_OBJECT,
SWAP_FAILED_ERROR,
} from '../../helpers/constants/swaps'
import { SWAP, SWAP_APPROVAL } from '../../helpers/constants/transactions'
import { fetchBasicGasAndTimeEstimates, fetchGasEstimates, resetCustomData } from '../gas/gas.duck'
import { formatCurrency } from '../../helpers/utils/confirm-tx.util'
const initialState = {
aggregatorMetadata: null,
approveTxId: null,
balanceError: false,
fetchingQuotes: false,
fromToken: null,
quotesFetchStartTime: null,
topAssets: {},
toToken: null,
metamaskFeeAmount: null,
}
const slice = createSlice({
name: 'swaps',
initialState,
reducers: {
clearSwapsState: () => initialState,
navigatedBackToBuildQuote: (state) => {
state.approveTxId = null
state.balanceError = false
state.fetchingQuotes = false
},
retriedGetQuotes: (state) => {
state.approveTxId = null
state.balanceError = false
state.fetchingQuotes = false
},
setAggregatorMetadata: (state, action) => {
state.aggregatorMetadata = action.payload
},
setBalanceError: (state, action) => {
state.balanceError = action.payload
},
setFetchingQuotes: (state, action) => {
state.fetchingQuotes = action.payload
},
setFromToken: (state, action) => {
state.fromToken = action.payload
},
setQuotesFetchStartTime: (state, action) => {
state.quotesFetchStartTime = action.payload
},
setTopAssets: (state, action) => {
state.topAssets = action.payload
},
setToToken: (state, action) => {
state.toToken = action.payload
},
setMetamaskFeeAmount: (state, action) => {
state.metamaskFeeAmount = action.payload
},
},
})
const { actions, reducer } = slice
export default reducer
// Selectors
export const getAggregatorMetadata = (state) => state.swaps.aggregatorMetadata
export const getBalanceError = (state) => state.swaps.balanceError
export const getFromToken = (state) => state.swaps.fromToken
export const getTopAssets = (state) => state.swaps.topAssets
export const getToToken = (state) => state.swaps.toToken
export const getMetaMaskFeeAmount = (state) => state.swaps.metamaskFeeAmount
export const getFetchingQuotes = (state) => state.swaps.fetchingQuotes
export const getQuotesFetchStartTime = (state) => state.swaps.quotesFetchStartTime
// Background selectors
const getSwapsState = (state) => state.metamask.swapsState
export const getSwapsFeatureLiveness = (state) => state.metamask.swapsState.swapsFeatureIsLive
export const getBackgroundSwapRouteState = (state) => state.metamask.swapsState.routeState
export const getCustomSwapsGas = (state) => state.metamask.swapsState.customMaxGas
export const getCustomSwapsGasPrice = (state) => state.metamask.swapsState.customGasPrice
export const getFetchParams = (state) => state.metamask.swapsState.fetchParams
export const getMaxMode = (state) => state.metamask.swapsState.maxMode
export const getQuotes = (state) => state.metamask.swapsState.quotes
export const getQuotesLastFetched = (state) => state.metamask.swapsState.quotesLastFetched
export const getSelectedQuote = (state) => {
const { selectedAggId, quotes } = getSwapsState(state)
return quotes[selectedAggId]
}
export const getSwapsErrorKey = (state) => getSwapsState(state)?.errorKey
export const getShowQuoteLoadingScreen = (state) => state.swaps.showQuoteLoadingScreen
export const getSwapsTokens = (state) => state.metamask.swapsState.tokens
export const getSwapsWelcomeMessageSeenStatus = (state) => state.metamask.swapsWelcomeMessageHasBeenShown
export const getTopQuote = (state) => {
const { topAggId, quotes } = getSwapsState(state)
return quotes[topAggId]
}
export const getApproveTxId = (state) => state.metamask.swapsState.approveTxId
export const getTradeTxId = (state) => state.metamask.swapsState.tradeTxId
export const getUsedQuote = (state) => getSelectedQuote(state) || getTopQuote(state)
// Compound selectors
export const getDestinationTokenInfo = (state) => getFetchParams(state)?.metaData?.destinationTokenInfo
export const getSwapsTradeTxParams = (state) => {
const { selectedAggId, topAggId, quotes } = getSwapsState(state)
const usedQuote = selectedAggId ? quotes[selectedAggId] : quotes[topAggId]
if (!usedQuote) {
return null
}
const { trade } = usedQuote
const gas = getCustomSwapsGas(state) || trade.gas
const gasPrice = getCustomSwapsGasPrice(state) || trade.gasPrice
return { ...trade, gas, gasPrice }
}
export const getApproveTxParams = (state) => {
const { approvalNeeded } = getSelectedQuote(state) || getTopQuote(state) || {}
if (!approvalNeeded) {
return null
}
const data = getSwapsState(state)?.customApproveTxData || approvalNeeded.data
const gasPrice = getCustomSwapsGasPrice(state) || approvalNeeded.gasPrice
return { ...approvalNeeded, gasPrice, data }
}
// Actions / action-creators
const {
clearSwapsState,
navigatedBackToBuildQuote,
retriedGetQuotes,
setAggregatorMetadata,
setBalanceError,
setFetchingQuotes,
setFromToken,
setQuotesFetchStartTime,
setTopAssets,
setToToken,
setMetamaskFeeAmount,
} = actions
export {
clearSwapsState,
setAggregatorMetadata,
setBalanceError,
setFetchingQuotes,
setFromToken as setSwapsFromToken,
setQuotesFetchStartTime as setSwapQuotesFetchStartTime,
setTopAssets,
setToToken as setSwapToToken,
setMetamaskFeeAmount,
}
export const navigateBackToBuildQuote = (history) => {
return async (dispatch) => {
// TODO: Ensure any fetch in progress is cancelled
await dispatch(resetSwapsPostFetchState())
dispatch(navigatedBackToBuildQuote())
history.push(BUILD_QUOTE_ROUTE)
}
}
export const prepareForRetryGetQuotes = () => {
return async (dispatch) => {
// TODO: Ensure any fetch in progress is cancelled
await dispatch(resetSwapsPostFetchState())
dispatch(retriedGetQuotes())
}
}
export const prepareToLeaveSwaps = () => {
return async (dispatch) => {
dispatch(resetCustomData())
dispatch(clearSwapsState())
await dispatch(resetBackgroundSwapsState())
}
}
export const fetchAndSetSwapsGasPriceInfo = () => {
return async (dispatch) => {
const basicEstimates = await dispatch(fetchBasicGasAndTimeEstimates())
dispatch(setSwapsTxGasPrice(decGWEIToHexWEI(basicEstimates.fast)))
await dispatch(fetchGasEstimates(basicEstimates.blockTime))
}
}
export const fetchQuotesAndSetQuoteState = (history, inputValue, maxSlippage, metaMetricsEvent) => {
return async (dispatch, getState) => {
let swapsFeatureIsLive = false
try {
swapsFeatureIsLive = await fetchSwapsFeatureLiveness()
} catch (error) {
log.error('Failed to fetch Swaps liveness, defaulting to false.', error)
}
await dispatch(setSwapsLiveness(swapsFeatureIsLive))
if (!swapsFeatureIsLive) {
await history.push(SWAPS_MAINTENANCE_ROUTE)
return
}
const state = getState()
const fetchParams = getFetchParams(state)
const selectedAccount = getSelectedAccount(state)
const balanceError = getBalanceError(state)
const fetchParamsFromToken = fetchParams?.metaData?.sourceTokenInfo?.symbol === 'ETH' ?
{
...ETH_SWAPS_TOKEN_OBJECT,
string: getValueFromWeiHex({ value: selectedAccount.balance, numberOfDecimals: 4, toDenomination: 'ETH' }),
balance: selectedAccount.balance,
} :
fetchParams?.metaData?.sourceTokenInfo
const selectedFromToken = getFromToken(state) || fetchParamsFromToken || {}
const selectedToToken = getToToken(state) || fetchParams?.metaData?.destinationTokenInfo || {}
const {
address: fromTokenAddress,
symbol: fromTokenSymbol,
decimals: fromTokenDecimals,
iconUrl: fromTokenIconUrl,
balance: fromTokenBalance,
} = selectedFromToken
const {
address: toTokenAddress,
symbol: toTokenSymbol,
decimals: toTokenDecimals,
iconUrl: toTokenIconUrl,
} = selectedToToken
await dispatch(setBackgroundSwapRouteState('loading'))
history.push(LOADING_QUOTES_ROUTE)
dispatch(setFetchingQuotes(true))
const contractExchangeRates = getTokenExchangeRates(state)
let destinationTokenAddedForSwap = false
if (toTokenSymbol !== 'ETH' && !contractExchangeRates[toTokenAddress]) {
destinationTokenAddedForSwap = true
await dispatch(addToken(toTokenAddress, toTokenSymbol, toTokenDecimals, toTokenIconUrl, true))
}
if (fromTokenSymbol !== 'ETH' && !contractExchangeRates[fromTokenAddress] && fromTokenBalance && (new BigNumber(fromTokenBalance, 16)).gt(0)) {
dispatch(addToken(fromTokenAddress, fromTokenSymbol, fromTokenDecimals, fromTokenIconUrl, true))
}
const swapsTokens = getSwapsTokens(state)
const sourceTokenInfo = swapsTokens?.find(({ address }) => address === fromTokenAddress) || selectedFromToken
const destinationTokenInfo = swapsTokens?.find(({ address }) => address === toTokenAddress) || selectedToToken
dispatch(setFromToken(selectedFromToken))
const maxMode = getMaxMode(state)
metaMetricsEvent({
event: 'Quotes Requested',
category: 'swaps',
})
metaMetricsEvent({
event: 'Quotes Requested',
category: 'swaps',
excludeMetaMetricsId: true,
properties: {
token_from: fromTokenSymbol,
token_from_amount: String(inputValue),
token_to: toTokenSymbol,
request_type: balanceError ? 'Quote' : 'Order',
slippage: maxSlippage,
custom_slippage: maxSlippage !== 2,
anonymizedData: true,
},
})
try {
const fetchStartTime = Date.now()
dispatch(setQuotesFetchStartTime(fetchStartTime))
const fetchAndSetQuotesPromise = dispatch(fetchAndSetQuotes(
{
slippage: maxSlippage,
sourceToken: fromTokenAddress,
destinationToken: toTokenAddress,
value: inputValue,
fromAddress: selectedAccount.address,
destinationTokenAddedForSwap,
balanceError,
sourceDecimals: fromTokenDecimals,
},
{
maxMode,
sourceTokenInfo,
destinationTokenInfo,
accountBalance: selectedAccount.balance,
},
))
const gasPriceFetchPromise = dispatch(fetchAndSetSwapsGasPriceInfo())
const [[fetchedQuotes, selectedAggId]] = await Promise.all([fetchAndSetQuotesPromise, gasPriceFetchPromise])
if (Object.values(fetchedQuotes)?.length === 0) {
metaMetricsEvent({
event: 'No Quotes Available',
category: 'swaps',
})
metaMetricsEvent({
event: 'No Quotes Available',
category: 'swaps',
excludeMetaMetricsId: true,
properties: {
token_from: fromTokenSymbol,
token_from_amount: String(inputValue),
token_to: toTokenSymbol,
request_type: balanceError ? 'Quote' : 'Order',
slippage: maxSlippage,
custom_slippage: maxSlippage !== 2,
},
})
dispatch(setSwapsErrorKey(QUOTES_NOT_AVAILABLE_ERROR))
} else {
const newSelectedQuote = fetchedQuotes[selectedAggId]
metaMetricsEvent({
event: 'Quotes Received',
category: 'swaps',
})
metaMetricsEvent({
event: 'Quotes Received',
category: 'swaps',
excludeMetaMetricsId: true,
properties: {
token_from: fromTokenSymbol,
token_from_amount: String(inputValue),
token_to: toTokenSymbol,
token_to_amount: calcTokenAmount(newSelectedQuote.destinationAmount, newSelectedQuote.decimals || 18),
request_type: balanceError ? 'Quote' : 'Order',
slippage: maxSlippage,
custom_slippage: maxSlippage !== 2,
response_time: Date.now() - fetchStartTime,
best_quote_source: newSelectedQuote.aggregator,
available_quotes: Object.values(fetchedQuotes)?.length,
anonymizedData: true,
},
})
dispatch(setInitialGasEstimate(selectedAggId, newSelectedQuote.maxGas))
}
} catch (e) {
dispatch(setSwapsErrorKey(ERROR_FETCHING_QUOTES))
}
dispatch(setFetchingQuotes(false))
}
}
export const signAndSendTransactions = (history, metaMetricsEvent) => {
return async (dispatch, getState) => {
let swapsFeatureIsLive = false
try {
swapsFeatureIsLive = await fetchSwapsFeatureLiveness()
} catch (error) {
log.error('Failed to fetch Swaps liveness, defaulting to false.', error)
}
await dispatch(setSwapsLiveness(swapsFeatureIsLive))
if (!swapsFeatureIsLive) {
await history.push(SWAPS_MAINTENANCE_ROUTE)
return
}
const state = getState()
const customSwapsGas = getCustomSwapsGas(state)
const fetchParams = getFetchParams(state)
const { metaData, value: swapTokenValue, slippage } = fetchParams
const { sourceTokenInfo = {}, destinationTokenInfo = {} } = metaData
await dispatch(setBackgroundSwapRouteState('awaiting'))
await dispatch(stopPollingForQuotes())
history.push(AWAITING_SWAP_ROUTE)
const usedQuote = getUsedQuote(state)
let usedTradeTxParams = usedQuote.trade
const estimatedGasLimit = new BigNumber(usedQuote?.gasEstimate || decimalToHex(usedQuote?.averageGas || 0), 16)
const estimatedGasLimitWithMultiplier = estimatedGasLimit.times(1.4, 10).round(0).toString(16)
const maxGasLimit = customSwapsGas || hexMax((`0x${decimalToHex(usedQuote?.maxGas || 0)}`), estimatedGasLimitWithMultiplier)
usedTradeTxParams.gas = maxGasLimit
const customConvertGasPrice = getCustomSwapsGasPrice(state)
const tradeTxParams = getSwapsTradeTxParams(state)
const fastGasEstimate = getFastPriceEstimateInHexWEI(state)
const usedGasPrice = customConvertGasPrice || tradeTxParams?.gasPrice || fastGasEstimate
usedTradeTxParams.gasPrice = usedGasPrice
const totalGasLimitForCalculation = (new BigNumber(usedTradeTxParams.gas, 16)).plus(usedQuote.approvalNeeded?.gas || '0x0', 16).toString(16)
const gasTotalInWeiHex = calcGasTotal(totalGasLimitForCalculation, usedGasPrice)
const maxMode = getMaxMode(state)
const selectedAccount = getSelectedAccount(state)
if (maxMode && sourceTokenInfo.symbol === 'ETH') {
const ethBalance = selectedAccount.balance
if ((new BigNumber(ethBalance, 16)).lte(gasTotalInWeiHex, 16)) {
// If somehow signAndSendTransactions was called when the users eth balance is less than the gas cost of a swap, an error has occured.
// The swap transaction should not be created.
dispatch(setSwapsErrorKey(SWAP_FAILED_ERROR))
history.push(SWAPS_ERROR_ROUTE)
return
}
const revisedTradeValueInHexWei = (new BigNumber(ethBalance, 16)).minus(gasTotalInWeiHex, 16).toString(16)
const revisedTradeValueInEth = hexWEIToDecETH(revisedTradeValueInHexWei)
const revisedQuotes = await fetchTradesInfo({
sourceToken: sourceTokenInfo.address,
destinationToken: destinationTokenInfo.address,
slippage,
value: revisedTradeValueInEth,
exchangeList: usedQuote.aggregator,
fromAddress: selectedAccount.address,
timeout: 10000,
sourceDecimals: 18,
})
const revisedQuote = Object.values(revisedQuotes)[0]
usedTradeTxParams = constructTxParams({
...revisedQuote.trade,
gas: decimalToHex(usedTradeTxParams.gas),
amount: decimalToHex(revisedQuote.trade.value),
gasPrice: tradeTxParams.gasPrice,
})
}
const conversionRate = getConversionRate(state)
const destinationValue = calcTokenAmount(usedQuote.destinationAmount, destinationTokenInfo.decimals || 18).toPrecision(8)
const usedGasLimitEstimate = usedQuote?.gasEstimateWithRefund || (`0x${decimalToHex(usedQuote?.averageGas || 0)}`)
const totalGasLimitEstimate = (new BigNumber(usedGasLimitEstimate, 16)).plus(usedQuote.approvalNeeded?.gas || '0x0', 16).toString(16)
const gasEstimateTotalInEth = getValueFromWeiHex({
value: calcGasTotal(totalGasLimitEstimate, usedGasPrice),
toCurrency: 'usd',
conversionRate,
numberOfDecimals: 6,
})
const swapMetaData = {
token_from: sourceTokenInfo.symbol,
token_from_amount: String(swapTokenValue),
token_to: destinationTokenInfo.symbol,
token_to_amount: destinationValue,
slippage,
custom_slippage: slippage !== 2,
best_quote_source: getTopQuote(state)?.aggregator,
available_quotes: getQuotes(state)?.length,
other_quote_selected: usedQuote.aggregator !== getTopQuote(state)?.aggregator,
other_quote_selected_source: usedQuote.aggregator === getTopQuote(state)?.aggregator ? '' : usedQuote.aggregator,
gas_fees: formatCurrency(gasEstimateTotalInEth, 'usd')?.slice(1),
}
const metaMetricsConfig = {
event: 'Swap Started',
category: 'swaps',
}
metaMetricsEvent({ ...metaMetricsConfig })
metaMetricsEvent({ ...metaMetricsConfig, excludeMetaMetricsId: true, properties: swapMetaData })
const approveTxParams = getApproveTxParams(state)
if (approveTxParams) {
const approveTxMeta = await dispatch(addUnapprovedTransaction({ ...approveTxParams, amount: '0x0' }, 'metamask'))
await dispatch(setApproveTxId(approveTxMeta.id))
const finalApproveTxMeta = await (dispatch(updateTransaction({
...approveTxMeta,
transactionCategory: SWAP_APPROVAL,
sourceTokenSymbol: sourceTokenInfo.symbol,
}, true)))
try {
await dispatch(updateAndApproveTx(finalApproveTxMeta, true))
} catch (e) {
await dispatch(setSwapsErrorKey(SWAP_FAILED_ERROR))
history.push(SWAPS_ERROR_ROUTE)
return
}
}
const tradeTxMeta = await dispatch(addUnapprovedTransaction(usedTradeTxParams, 'metamask'))
dispatch(setTradeTxId(tradeTxMeta.id))
const finalTradeTxMeta = await (dispatch(updateTransaction({
...tradeTxMeta,
sourceTokenSymbol: sourceTokenInfo.symbol,
destinationTokenSymbol: destinationTokenInfo.symbol,
transactionCategory: SWAP,
destinationTokenDecimals: destinationTokenInfo.decimals,
destinationTokenAddress: destinationTokenInfo.address,
swapMetaData,
swapTokenValue,
}, true)))
try {
await dispatch(updateAndApproveTx(finalTradeTxMeta, true))
} catch (e) {
await dispatch(setSwapsErrorKey(SWAP_FAILED_ERROR))
history.push(SWAPS_ERROR_ROUTE)
return
}
await forceUpdateMetamaskState(dispatch)
}
}

View File

@ -30,6 +30,13 @@ const CONNECT_ROUTE = '/connect'
const CONNECT_CONFIRM_PERMISSIONS_ROUTE = '/confirm-permissions'
const CONNECTED_ROUTE = '/connected'
const CONNECTED_ACCOUNTS_ROUTE = '/connected/accounts'
const SWAPS_ROUTE = '/swaps'
const BUILD_QUOTE_ROUTE = '/swaps/build-quote'
const VIEW_QUOTE_ROUTE = '/swaps/view-quote'
const LOADING_QUOTES_ROUTE = '/swaps/loading-quotes'
const AWAITING_SWAP_ROUTE = '/swaps/awaiting-swap'
const SWAPS_ERROR_ROUTE = '/swaps/swaps-error'
const SWAPS_MAINTENANCE_ROUTE = '/swaps/maintenance'
const INITIALIZE_ROUTE = '/initialize'
const INITIALIZE_WELCOME_ROUTE = '/initialize/welcome'
@ -109,6 +116,11 @@ const PATH_NAME_MAP = {
[INITIALIZE_END_OF_FLOW_ROUTE]: 'End of Initialization Page',
[INITIALIZE_CONFIRM_SEED_PHRASE_ROUTE]: 'Initialization Confirm Seed Phrase Page',
[INITIALIZE_METAMETRICS_OPT_IN_ROUTE]: 'MetaMetrics Opt In Page',
[BUILD_QUOTE_ROUTE]: 'Swaps Build Quote Page',
[VIEW_QUOTE_ROUTE]: 'Swaps View Quotes Page',
[LOADING_QUOTES_ROUTE]: 'Swaps Loading Quotes Page',
[AWAITING_SWAP_ROUTE]: 'Swaps Awaiting Swaps Page',
[SWAPS_ERROR_ROUTE]: 'Swaps Error Page',
}
export {
@ -166,4 +178,11 @@ export {
CONNECTED_ROUTE,
CONNECTED_ACCOUNTS_ROUTE,
PATH_NAME_MAP,
SWAPS_ROUTE,
BUILD_QUOTE_ROUTE,
VIEW_QUOTE_ROUTE,
LOADING_QUOTES_ROUTE,
AWAITING_SWAP_ROUTE,
SWAPS_ERROR_ROUTE,
SWAPS_MAINTENANCE_ROUTE,
}

View File

@ -0,0 +1,21 @@
// An address that the metaswap-api recognizes as ETH, in place of the token address that ERC-20 tokens have
export const ETH_SWAPS_TOKEN_ADDRESS = '0x0000000000000000000000000000000000000000'
export const ETH_SWAPS_TOKEN_OBJECT = {
symbol: 'ETH',
name: 'Ether',
address: ETH_SWAPS_TOKEN_ADDRESS,
decimals: 18,
iconUrl: 'images/black-eth-logo.svg',
}
export const QUOTES_EXPIRED_ERROR = 'quotes-expired'
export const SWAP_FAILED_ERROR = 'swap-failed-error'
export const ERROR_FETCHING_QUOTES = 'error-fetching-quotes'
export const QUOTES_NOT_AVAILABLE_ERROR = 'quotes-not-avilable'
export const OFFLINE_FOR_MAINTENANCE = 'offline-for-maintenance'
// A gas value for ERC20 approve calls that should be sufficient for all ERC20 approve implementations
export const DEFAULT_ERC20_APPROVE_GAS = '0x1d4c0'
export const SWAPS_CONTRACT_ADDRESS = '0x016b4bf68d421147c06f1b8680602c5bf0df91a8'

View File

@ -29,6 +29,9 @@ export const TOKEN_CATEGORY_HASH = {
[TOKEN_METHOD_TRANSFER_FROM]: true,
}
export const SWAP = 'swap'
export const SWAP_APPROVAL = 'swapApproval'
export const INCOMING_TRANSACTION = 'incoming'
export const SEND_ETHER_ACTION_KEY = 'sentEther'
@ -50,3 +53,4 @@ export const TRANSACTION_CATEGORY_RECEIVE = 'receive'
export const TRANSACTION_CATEGORY_INTERACTION = 'interaction'
export const TRANSACTION_CATEGORY_APPROVAL = 'approval'
export const TRANSACTION_CATEGORY_SIGNATURE_REQUEST = 'signature-request'
export const TRANSACTION_CATEGORY_SWAP = 'swap'

View File

@ -0,0 +1,16 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Redirect, Route } from 'react-router-dom'
export default function FeatureToggledRoute ({ flag, redirectRoute, ...props }) {
if (flag) {
return <Route {...props} />
}
return <Redirect to={{ pathname: redirectRoute }} />
}
FeatureToggledRoute.propTypes = {
flag: PropTypes.bool.isRequired,
redirectRoute: PropTypes.string.isRequired,
}

View File

@ -1,6 +1,10 @@
import ethUtil from 'ethereumjs-util'
import BigNumber from 'bignumber.js'
import { ETH, GWEI, WEI } from '../constants/common'
import { conversionUtil, addCurrencies, subtractCurrencies } from './conversion-util'
import {
formatCurrency,
} from './confirm-tx.util'
export function bnToHex (inputBn) {
return ethUtil.addHexPrefix(inputBn.toString(16))
@ -138,3 +142,43 @@ export function decETHToDecWEI (decEth) {
toDenomination: 'WEI',
})
}
export function hexWEIToDecETH (hexWEI) {
return conversionUtil(hexWEI, {
fromNumericBase: 'hex',
toNumericBase: 'dec',
fromDenomination: 'WEI',
toDenomination: 'ETH',
})
}
export function hexMax (...hexNumbers) {
let max = hexNumbers[0]
hexNumbers.slice(1).forEach((hexNumber) => {
if ((new BigNumber(hexNumber, 16)).gt(max, 16)) {
max = hexNumber
}
})
return max
}
export function addHexes (aHexWEI, bHexWEI) {
return addCurrencies(aHexWEI, bHexWEI, {
aBase: 16,
bBase: 16,
toNumericBase: 'hex',
numberOfDecimals: 6,
})
}
export function sumHexWEIsToRenderableFiat (hexWEIs, convertedCurrency, conversionRate) {
const hexWEIsSum = hexWEIs.filter((n) => n).reduce(addHexes)
const ethTotal = decEthToConvertedCurrency(
getValueFromWeiHex({
value: hexWEIsSum, toCurrency: 'ETH', numberOfDecimals: 4,
}),
convertedCurrency,
conversionRate,
)
return formatCurrency(ethTotal, convertedCurrency)
}

View File

@ -163,22 +163,31 @@ export function getTokenValueParam (tokenData = {}) {
return tokenData?.args?.['_value']?.toString()
}
export function getTokenValue (tokenParams = []) {
const valueData = tokenParams.find((param) => param.name === '_value')
return valueData && valueData.value
}
/**
* Get the token balance converted to fiat and formatted for display
* Get the token balance converted to fiat and optionally formatted for display
*
* @param {number} [contractExchangeRate] - The exchange rate between the current token and the native currency
* @param {number} conversionRate - The exchange rate between the current fiat currency and the native currency
* @param {string} currentCurrency - The currency code for the user's chosen fiat currency
* @param {string} [tokenAmount] - The current token balance
* @param {string} [tokenSymbol] - The token symbol
* @returns {string|undefined} The formatted token amount in the user's chosen fiat currency
* @param {boolean} [formatted] - Whether the return value should be formatted or not
* @param {boolean} [hideCurrencySymbol] - excludes the currency symbol in the result if true
* @returns {string|undefined} The token amount in the user's chosen fiat currency, optionally formatted and localize
*/
export function getFormattedTokenFiatAmount (
export function getTokenFiatAmount (
contractExchangeRate,
conversionRate,
currentCurrency,
tokenAmount,
tokenSymbol,
formatted = true,
hideCurrencySymbol = false,
) {
// If the conversionRate is 0 (i.e. unknown) or the contract exchange rate
// is currently unknown, the fiat amount cannot be calculated so it is not
@ -198,5 +207,13 @@ export function getFormattedTokenFiatAmount (
numberOfDecimals: 2,
conversionRate: currentTokenToFiatRate,
})
return `${formatCurrency(currentTokenInFiat, currentCurrency)} ${currentCurrency.toUpperCase()}`
let result
if (hideCurrencySymbol) {
result = formatCurrency(currentTokenInFiat, currentCurrency)
} else if (formatted) {
result = `${formatCurrency(currentTokenInFiat, currentCurrency)} ${currentCurrency.toUpperCase()}`
} else {
result = currentTokenInFiat
}
return result
}

View File

@ -369,3 +369,41 @@ export function toPrecisionWithoutTrailingZeros (n, precision) {
.toPrecision(precision)
.replace(/(\.[0-9]*[1-9])0*|(\.0*)/u, '$1')
}
/**
* Given and object where all values are strings, returns the same object with all values
* now prefixed with '0x'
*/
export function addHexPrefixToObjectValues (obj) {
return Object.keys(obj).reduce((newObj, key) => {
return { ...newObj, [key]: ethUtil.addHexPrefix(obj[key]) }
}, {})
}
/**
* Given the standard set of information about a transaction, returns a transaction properly formatted for
* publishing via JSON RPC and web3
*
* @param {string|object|boolean} sendToken - Indicates whether or not the transaciton is a token transaction
* @param {string} data - A hex string containing the data to include in the transaction
* @param {string} to - A hex address of the tx recipient address
* @param {string} from - A hex address of the tx sender address
* @param {string} gas - A hex representation of the gas value for the transaction
* @param {string} gasPrice - A hex representation of the gas price for the transaction
* @returns {object} An object ready for submission to the blockchain, with all values appropriately hex prefixed
*/
export function constructTxParams ({ sendToken, data, to, amount, from, gas, gasPrice }) {
const txParams = {
data,
from,
value: '0',
gas,
gasPrice,
}
if (!sendToken) {
txParams.value = amount
txParams.to = to
}
return addHexPrefixToObjectValues(txParams)
}

View File

@ -392,4 +392,21 @@ describe('util', function () {
})
})
})
describe('addHexPrefixToObjectValues()', function () {
it('should return a new object with the same properties with a 0x prefix', function () {
assert.deepEqual(
util.addHexPrefixToObjectValues({
prop1: '0x123',
prop2: '456',
prop3: 'x',
}),
{
prop1: '0x123',
prop2: '0x456',
prop3: '0xx',
},
)
})
})
})

View File

@ -1,7 +1,9 @@
import assert from 'assert'
import React from 'react'
import * as reactRedux from 'react-redux'
import { renderHook } from '@testing-library/react-hooks'
import sinon from 'sinon'
import { MemoryRouter } from 'react-router-dom'
import transactions from '../../../../test/data/transaction-data.json'
import { useTransactionDisplayData } from '../useTransactionDisplayData'
import * as useTokenFiatAmountHooks from '../useTokenFiatAmount'
@ -10,6 +12,7 @@ import { getTokens } from '../../ducks/metamask/metamask'
import * as i18nhooks from '../useI18nContext'
import { getMessage } from '../../helpers/utils/i18n-helper'
import messages from '../../../../app/_locales/en/messages.json'
import { ASSET_ROUTE, DEFAULT_ROUTE } from '../../helpers/constants/routes'
const expectedResults = [
{
@ -90,10 +93,30 @@ const expectedResults = [
isPending: false,
status: 'confirmed',
},
{
title: 'Swap ETH to ABC',
category: 'swap',
subtitle: '',
subtitleContainsOrigin: false,
date: 'May 12',
primaryCurrency: '+1 ABC',
senderAddress: '0xee014609ef9e09776ac5fe00bdbfef57bcdefebb',
recipientAddress: '0xabca64466f257793eaa52fcfff5066894b76a149',
secondaryCurrency: undefined,
isPending: false,
status: 'confirmed',
},
]
let useSelector, useI18nContext, useTokenFiatAmount
const renderHookWithRouter = (cb, tokenAddress) => {
const initialEntries = [tokenAddress ? `${ASSET_ROUTE}/${tokenAddress}` : DEFAULT_ROUTE]
// eslint-disable-next-line
const wrapper = ({ children }) => <MemoryRouter initialEntries={initialEntries}>{children}</MemoryRouter>
return renderHook(cb, { wrapper })
}
describe('useTransactionDisplayData', function () {
before(function () {
useSelector = sinon.stub(reactRedux, 'useSelector')
@ -105,7 +128,7 @@ describe('useTransactionDisplayData', function () {
useI18nContext.returns((key, variables) => getMessage('en', messages, key, variables))
useSelector.callsFake((selector) => {
if (selector === getTokens) {
return []
return [{ address: '0xabca64466f257793eaa52fcfff5066894b76a149', symbol: 'ABC', decimals: 18 }]
} else if (selector === getPreferences) {
return {
useNativeCurrencyAsPrimaryCurrency: true,
@ -123,42 +146,43 @@ describe('useTransactionDisplayData', function () {
transactions.forEach((transactionGroup, idx) => {
describe(`when called with group containing primaryTransaction id ${transactionGroup.primaryTransaction.id}`, function () {
const expected = expectedResults[idx]
const tokenAddress = transactionGroup.primaryTransaction?.destinationTokenAddress
it(`should return a title of ${expected.title}`, function () {
const { result } = renderHook(() => useTransactionDisplayData(transactionGroup))
const { result } = renderHookWithRouter(() => useTransactionDisplayData(transactionGroup), tokenAddress)
assert.equal(result.current.title, expected.title)
})
it(`should return a subtitle of ${expected.subtitle}`, function () {
const { result } = renderHook(() => useTransactionDisplayData(transactionGroup))
const { result } = renderHookWithRouter(() => useTransactionDisplayData(transactionGroup), tokenAddress)
assert.equal(result.current.subtitle, expected.subtitle)
})
it(`should return a category of ${expected.category}`, function () {
const { result } = renderHook(() => useTransactionDisplayData(transactionGroup))
const { result } = renderHookWithRouter(() => useTransactionDisplayData(transactionGroup), tokenAddress)
assert.equal(result.current.category, expected.category)
})
it(`should return a primaryCurrency of ${expected.primaryCurrency}`, function () {
const { result } = renderHook(() => useTransactionDisplayData(transactionGroup))
const { result } = renderHookWithRouter(() => useTransactionDisplayData(transactionGroup), tokenAddress)
assert.equal(result.current.primaryCurrency, expected.primaryCurrency)
})
it(`should return a secondaryCurrency of ${expected.secondaryCurrency}`, function () {
const { result } = renderHook(() => useTransactionDisplayData(transactionGroup))
const { result } = renderHookWithRouter(() => useTransactionDisplayData(transactionGroup), tokenAddress)
assert.equal(result.current.secondaryCurrency, expected.secondaryCurrency)
})
it(`should return a status of ${expected.status}`, function () {
const { result } = renderHook(() => useTransactionDisplayData(transactionGroup))
const { result } = renderHookWithRouter(() => useTransactionDisplayData(transactionGroup), tokenAddress)
assert.equal(result.current.status, expected.status)
})
it(`should return a recipientAddress of ${expected.recipientAddress}`, function () {
const { result } = renderHook(() => useTransactionDisplayData(transactionGroup))
const { result } = renderHookWithRouter(() => useTransactionDisplayData(transactionGroup), tokenAddress)
assert.equal(result.current.recipientAddress, expected.recipientAddress)
})
it(`should return a senderAddress of ${expected.senderAddress}`, function () {
const { result } = renderHook(() => useTransactionDisplayData(transactionGroup))
const { result } = renderHookWithRouter(() => useTransactionDisplayData(transactionGroup), tokenAddress)
assert.equal(result.current.senderAddress, expected.senderAddress)
})
})
})
it('should return an appropriate object', function () {
const { result } = renderHook(() => useTransactionDisplayData(transactions[0]))
const { result } = renderHookWithRouter(() => useTransactionDisplayData(transactions[0]))
assert.deepEqual(result.current, expectedResults[0])
})
after(function () {

View File

@ -0,0 +1,24 @@
import { useSelector } from 'react-redux'
import { useRouteMatch } from 'react-router-dom'
import { getTokens } from '../ducks/metamask/metamask'
import { ASSET_ROUTE } from '../helpers/constants/routes'
import { ETH_SWAPS_TOKEN_OBJECT } from '../helpers/constants/swaps'
/**
* Returns a token object for the asset that is currently being viewed.
* Will return the ETH_SWAPS_TOKEN_OBJECT when the user is viewing either
* the primary, unfiltered, activity list or the ETH asset page.
* @returns {import('./useTokenDisplayValue').Token}
*/
export function useCurrentAsset () {
// To determine which primary currency to display for swaps transactions we need to be aware
// of which asset, if any, we are viewing at present
const match = useRouteMatch({ path: `${ASSET_ROUTE}/:asset`, exact: true, strict: true })
const tokenAddress = match?.params?.asset
const knownTokens = useSelector(getTokens)
const token = tokenAddress && knownTokens.find(
({ address }) => address === tokenAddress,
)
return token ?? ETH_SWAPS_TOKEN_OBJECT
}

View File

@ -0,0 +1,34 @@
import { useMemo } from 'react'
import { useSelector } from 'react-redux'
import { getConversionRate, getCurrentCurrency, getShouldShowFiat } from '../selectors'
import { decEthToConvertedCurrency } from '../helpers/utils/conversions.util'
import { formatCurrency } from '../helpers/utils/confirm-tx.util'
/**
* Get an Eth amount converted to fiat and formatted for display
*
* @param {string} [tokenAmount] - The eth amount to convert
* @param {object} [overrides] - A configuration object that allows the called to explicitly
* ensure fiat is shown even if the property is not set in state.
* @param {boolean} [overrides.showFiat] - If truthy, ensures the fiat value is shown even if the showFiat value from state is falsey
* @param {boolean} hideCurrencySymbol Indicates whether the returned formatted amount should include the trailing currency symbol
* @return {string} - The formatted token amount in the user's chosen fiat currency
*/
export function useEthFiatAmount (ethAmount, overrides = {}, hideCurrencySymbol) {
const conversionRate = useSelector(getConversionRate)
const currentCurrency = useSelector(getCurrentCurrency)
const userPrefersShownFiat = useSelector(getShouldShowFiat)
const showFiat = overrides.showFiat ?? userPrefersShownFiat
const formattedFiat = useMemo(
() => decEthToConvertedCurrency(ethAmount, currentCurrency, conversionRate),
[conversionRate, currentCurrency, ethAmount],
)
if (!showFiat || currentCurrency.toUpperCase() === 'ETH' || conversionRate <= 0 || ethAmount === undefined) {
return undefined
}
return hideCurrencySymbol
? formatCurrency(formattedFiat, currentCurrency)
: `${formatCurrency(formattedFiat, currentCurrency)} ${currentCurrency.toUpperCase()}`
}

View File

@ -0,0 +1,9 @@
import { useEffect, useRef } from 'react'
export function usePrevious (value) {
const ref = useRef()
useEffect(() => {
ref.current = value
}, [value])
return ref.current
}

View File

@ -0,0 +1,56 @@
import { ETH_SWAPS_TOKEN_ADDRESS } from '../helpers/constants/swaps'
import { SWAP } from '../helpers/constants/transactions'
import { getSwapsTokensReceivedFromTxMeta } from '../pages/swaps/swaps.util'
import { useTokenFiatAmount } from './useTokenFiatAmount'
/**
* @typedef {Object} SwappedTokenValue
* @property {string} swapTokenValue - a primary currency string formatted for display
* @property {string} swapTokenFiatAmount - a secondary currency string formatted for display
* @property {boolean} isViewingReceivedTokenFromSwap - true if user is on the asset page for the
* destination/received asset in a swap.
*/
/**
* A SWAP transaction group's primaryTransaction contains details of the swap,
* including the source (from) and destination (to) token type (ETH, DAI, etc..)
* When viewing a non ETH asset page, we need to determine if that asset is the
* token that was received (destination) from the swap. In that circumstance we
* would want to show the primaryCurrency in the activity list that is most relevant
* for that token (- 1000 DAI, for example, when swapping DAI for ETH).
* @param {import('../selectors').transactionGroup} transactionGroup - Group of transactions by nonce
* @param {import('./useTokenDisplayValue').Token} currentAsset - The current asset the user is looking at
* @returns {SwappedTokenValue}
*/
export function useSwappedTokenValue (transactionGroup, currentAsset) {
const { symbol, decimals, address } = currentAsset
const { primaryTransaction, initialTransaction } = transactionGroup
const { transactionCategory } = initialTransaction
const { from: senderAddress } = initialTransaction.txParams || {}
const isViewingReceivedTokenFromSwap = (
(currentAsset?.symbol === primaryTransaction.destinationTokenSymbol) || (
currentAsset.address === ETH_SWAPS_TOKEN_ADDRESS &&
primaryTransaction.destinationTokenSymbol === 'ETH'
)
)
const swapTokenValue = transactionCategory === SWAP && isViewingReceivedTokenFromSwap
? getSwapsTokensReceivedFromTxMeta(
primaryTransaction.destinationTokenSymbol,
initialTransaction,
address,
senderAddress,
decimals,
)
: transactionCategory === SWAP && primaryTransaction.swapTokenValue
const _swapTokenFiatAmount = useTokenFiatAmount(
address,
swapTokenValue || '',
symbol,
)
const swapTokenFiatAmount = (
swapTokenValue && isViewingReceivedTokenFromSwap && _swapTokenFiatAmount
)
return { swapTokenValue, swapTokenFiatAmount, isViewingReceivedTokenFromSwap }
}

View File

@ -1,7 +1,7 @@
import { useMemo } from 'react'
import { useSelector } from 'react-redux'
import { getTokenExchangeRates, getConversionRate, getCurrentCurrency, getShouldShowFiat } from '../selectors'
import { getFormattedTokenFiatAmount } from '../helpers/utils/token-util'
import { getTokenFiatAmount } from '../helpers/utils/token-util'
/**
* Get the token balance converted to fiat and formatted for display
@ -9,25 +9,31 @@ import { getFormattedTokenFiatAmount } from '../helpers/utils/token-util'
* @param {string} [tokenAddress] - The token address
* @param {string} [tokenAmount] - The token balance
* @param {string} [tokenSymbol] - The token symbol
* @param {object} [overrides] - A configuration object that allows the caller to explicitly pass an exchange rate or
* ensure fiat is shown even if the property is not set in state.
* @param {number} [overrides.exchangeRate] - An exhchange rate to use instead of the one selected from state
* @param {boolean} [overrides.showFiat] - If truthy, ensures the fiat value is shown even if the showFiat value from state is falsey
* @param {boolean} hideCurrencySymbol Indicates whether the returned formatted amount should include the trailing currency symbol
* @return {string} - The formatted token amount in the user's chosen fiat currency
*/
export function useTokenFiatAmount (tokenAddress, tokenAmount, tokenSymbol) {
export function useTokenFiatAmount (tokenAddress, tokenAmount, tokenSymbol, overrides = {}, hideCurrencySymbol) {
const contractExchangeRates = useSelector(getTokenExchangeRates)
const conversionRate = useSelector(getConversionRate)
const currentCurrency = useSelector(getCurrentCurrency)
const showFiat = useSelector(getShouldShowFiat)
const tokenExchangeRate = contractExchangeRates[tokenAddress]
const userPrefersShownFiat = useSelector(getShouldShowFiat)
const showFiat = overrides.showFiat ?? userPrefersShownFiat
const tokenExchangeRate = overrides.exchangeRate ?? contractExchangeRates[tokenAddress]
const formattedFiat = useMemo(
() => getFormattedTokenFiatAmount(
() => getTokenFiatAmount(
tokenExchangeRate,
conversionRate,
currentCurrency,
tokenAmount,
tokenSymbol,
true,
hideCurrencySymbol,
),
[tokenExchangeRate, conversionRate, currentCurrency, tokenAmount, tokenSymbol],
[tokenExchangeRate, conversionRate, currentCurrency, tokenAmount, tokenSymbol, hideCurrencySymbol],
)
if (!showFiat || currentCurrency.toUpperCase() === tokenSymbol) {

View File

@ -0,0 +1,115 @@
import { useMemo, useState } from 'react'
import { useSelector } from 'react-redux'
import contractMap from 'eth-contract-metadata'
import BigNumber from 'bignumber.js'
import { isEqual, shuffle } from 'lodash'
import { getValueFromWeiHex } from '../helpers/utils/conversions.util'
import { checksumAddress } from '../helpers/utils/util'
import { getTokenFiatAmount } from '../helpers/utils/token-util'
import { getTokenExchangeRates, getConversionRate, getCurrentCurrency } from '../selectors'
import { getSwapsTokens } from '../ducks/swaps/swaps'
import { ETH_SWAPS_TOKEN_OBJECT } from '../helpers/constants/swaps'
import { useEqualityCheck } from './useEqualityCheck'
const tokenList = shuffle(Object.entries(contractMap)
.map(([address, tokenData]) => ({ ...tokenData, address: address.toLowerCase() }))
.filter((tokenData) => Boolean(tokenData.erc20)))
export function getRenderableTokenData (token, contractExchangeRates, conversionRate, currentCurrency) {
const { symbol, name, address, iconUrl, string, balance, decimals } = token
const formattedFiat = getTokenFiatAmount(
symbol === 'ETH' ? 1 : contractExchangeRates[address],
conversionRate,
currentCurrency,
string,
symbol,
true,
) || ''
const rawFiat = getTokenFiatAmount(
symbol === 'ETH' ? 1 : contractExchangeRates[address],
conversionRate,
currentCurrency,
string,
symbol,
false,
) || ''
const usedIconUrl = iconUrl || (contractMap[checksumAddress(address)] && `images/contract/${contractMap[checksumAddress(address)].logo}`)
return {
...token,
primaryLabel: symbol,
secondaryLabel: name || contractMap[checksumAddress(address)]?.name,
rightPrimaryLabel: string && `${(new BigNumber(string)).round(6).toString()} ${symbol}`,
rightSecondaryLabel: formattedFiat,
iconUrl: usedIconUrl,
identiconAddress: usedIconUrl ? null : address,
balance,
decimals,
name: name || contractMap[checksumAddress(address)]?.name,
rawFiat,
}
}
export function useTokensToSearch ({ providedTokens, rawEthBalance, usersTokens = [], topTokens = {}, onlyEth, singleToken }) {
const tokenConversionRates = useSelector(getTokenExchangeRates, isEqual)
const conversionRate = useSelector(getConversionRate)
const currentCurrency = useSelector(getCurrentCurrency)
const memoizedTopTokens = useEqualityCheck(topTokens)
const memoizedUsersToken = useEqualityCheck(usersTokens)
const decEthBalance = getValueFromWeiHex({ value: rawEthBalance, numberOfDecimals: 4, toDenomination: 'ETH' })
const [ethToken] = useState(() => getRenderableTokenData(
{ ...ETH_SWAPS_TOKEN_OBJECT, balance: rawEthBalance, string: decEthBalance },
tokenConversionRates,
conversionRate,
currentCurrency,
))
const swapsTokens = useSelector(getSwapsTokens) || []
let tokensToSearch
if (onlyEth) {
tokensToSearch = [ethToken]
} else if (singleToken) {
tokensToSearch = providedTokens
} else if (providedTokens) {
tokensToSearch = [ethToken, ...providedTokens]
} else if (swapsTokens.length) {
tokensToSearch = [ethToken, ...swapsTokens]
} else {
tokensToSearch = [ethToken, ...tokenList]
}
const memoizedTokensToSearch = useEqualityCheck(tokensToSearch)
return useMemo(() => {
const usersTokensAddressMap = memoizedUsersToken.reduce((acc, token) => ({ ...acc, [token.address]: token }), {})
const tokensToSearchBuckets = {
owned: singleToken ? [] : [ethToken],
top: [],
others: [],
}
memoizedTokensToSearch.forEach((token) => {
const renderableDataToken = getRenderableTokenData({ ...usersTokensAddressMap[token.address], ...token }, tokenConversionRates, conversionRate, currentCurrency)
if (usersTokensAddressMap[token.address] && ((renderableDataToken.symbol === 'ETH') || Number(renderableDataToken.balance ?? 0) !== 0)) {
tokensToSearchBuckets.owned.push(renderableDataToken)
} else if (memoizedTopTokens[token.address]) {
tokensToSearchBuckets.top[memoizedTopTokens[token.address].index] = renderableDataToken
} else {
tokensToSearchBuckets.others.push(renderableDataToken)
}
})
tokensToSearchBuckets.owned = tokensToSearchBuckets.owned.sort(({ rawFiat }, { rawFiat: secondRawFiat }) => {
return ((new BigNumber(rawFiat || 0)).gt(secondRawFiat || 0) ? -1 : 1)
})
tokensToSearchBuckets.top = tokensToSearchBuckets.top.filter((token) => token)
return [
...tokensToSearchBuckets.owned,
...tokensToSearchBuckets.top,
...tokensToSearchBuckets.others,
]
}, [memoizedTokensToSearch, memoizedUsersToken, tokenConversionRates, conversionRate, currentCurrency, memoizedTopTokens, ethToken, singleToken])
}

View File

@ -17,9 +17,12 @@ import {
TRANSACTION_CATEGORY_RECEIVE,
TRANSACTION_CATEGORY_SEND,
TRANSACTION_CATEGORY_SIGNATURE_REQUEST,
TRANSACTION_CATEGORY_SWAP,
TOKEN_METHOD_APPROVE,
PENDING_STATUS_HASH,
TOKEN_CATEGORY_HASH,
SWAP,
SWAP_APPROVAL,
} from '../helpers/constants/transactions'
import { getTokens } from '../ducks/metamask/metamask'
import { useI18nContext } from './useI18nContext'
@ -28,6 +31,8 @@ import { useUserPreferencedCurrency } from './useUserPreferencedCurrency'
import { useCurrencyDisplay } from './useCurrencyDisplay'
import { useTokenDisplayValue } from './useTokenDisplayValue'
import { useTokenData } from './useTokenData'
import { useSwappedTokenValue } from './useSwappedTokenValue'
import { useCurrentAsset } from './useCurrentAsset'
/**
* @typedef {Object} TransactionDisplayData
@ -53,6 +58,9 @@ import { useTokenData } from './useTokenData'
* @return {TransactionDisplayData}
*/
export function useTransactionDisplayData (transactionGroup) {
// To determine which primary currency to display for swaps transactions we need to be aware
// of which asset, if any, we are viewing at present
const currentAsset = useCurrentAsset()
const knownTokens = useSelector(getTokens)
const t = useI18nContext()
const { initialTransaction, primaryTransaction } = transactionGroup
@ -65,6 +73,7 @@ export function useTransactionDisplayData (transactionGroup) {
const methodData = useSelector((state) => getKnownMethodData(state, initialTransaction?.txParams?.data)) || {}
const status = getStatusKey(primaryTransaction)
const isPending = status in PENDING_STATUS_HASH
const primaryValue = primaryTransaction.txParams?.value
let prefix = '-'
@ -90,19 +99,58 @@ export function useTransactionDisplayData (transactionGroup) {
const origin = stripHttpSchemes(initialTransaction.origin || initialTransaction.msgParams?.origin || '')
// used to append to the primary display value. initialized to either token.symbol or undefined
// but can later be modified if dealing with a swap
let primarySuffix = isTokenCategory ? token?.symbol : undefined
// used to display the primary value of tx. initialized to either tokenDisplayValue or undefined
// but can later be modified if dealing with a swap
let primaryDisplayValue = isTokenCategory ? tokenDisplayValue : undefined
// used to display fiat amount of tx. initialized to either tokenFiatAmount or undefined
// but can later be modified if dealing with a swap
let secondaryDisplayValue = isTokenCategory ? tokenFiatAmount : undefined
// The transaction group category that will be used for rendering the icon in the activity list
let category
// The primary title of the Tx that will be displayed in the activity list
let title
// There are four types of transaction entries that are currently differentiated in the design
// 1. signature request
const { swapTokenValue, swapTokenFiatAmount, isViewingReceivedTokenFromSwap } = useSwappedTokenValue(transactionGroup, currentAsset)
// There are seven types of transaction entries that are currently differentiated in the design
// 1. Signature request
// 2. Send (sendEth sendTokens)
// 3. Deposit
// 4. Site interaction
// 5. Approval
// 6. Swap
// 7. Swap Approval
if (transactionCategory === null || transactionCategory === undefined) {
category = TRANSACTION_CATEGORY_SIGNATURE_REQUEST
title = t('signatureRequest')
subtitle = origin
subtitleContainsOrigin = true
} else if (transactionCategory === SWAP) {
category = TRANSACTION_CATEGORY_SWAP
title = t('swapTokenToToken', [
primaryTransaction.sourceTokenSymbol,
primaryTransaction.destinationTokenSymbol,
])
subtitle = origin
subtitleContainsOrigin = true
primarySuffix = isViewingReceivedTokenFromSwap
? currentAsset.symbol
: primaryTransaction.sourceTokenSymbol
primaryDisplayValue = swapTokenValue
secondaryDisplayValue = swapTokenFiatAmount
prefix = isViewingReceivedTokenFromSwap ? '+' : '-'
} else if (transactionCategory === SWAP_APPROVAL) {
category = TRANSACTION_CATEGORY_APPROVAL
title = t('swapApproval', [primaryTransaction.sourceTokenSymbol])
subtitle = origin
subtitleContainsOrigin = true
primarySuffix = primaryTransaction.sourceTokenSymbol
} else if (transactionCategory === TOKEN_METHOD_APPROVE) {
category = TRANSACTION_CATEGORY_APPROVAL
title = t('approveSpendLimit', [token?.symbol || t('token')])
@ -113,16 +161,19 @@ export function useTransactionDisplayData (transactionGroup) {
title = (methodData?.name && camelCaseToCapitalize(methodData.name)) || t(transactionCategory)
subtitle = origin
subtitleContainsOrigin = true
} else if (transactionCategory === INCOMING_TRANSACTION) {
category = TRANSACTION_CATEGORY_RECEIVE
title = t('receive')
prefix = ''
subtitle = t('fromAddress', [shortenAddress(senderAddress)])
} else if (transactionCategory === TOKEN_METHOD_TRANSFER_FROM || transactionCategory === TOKEN_METHOD_TRANSFER) {
category = TRANSACTION_CATEGORY_SEND
title = t('sendSpecifiedTokens', [token?.symbol || t('token')])
recipientAddress = getTokenAddressParam(tokenData)
recipientAddress = getTokenAddressParam(tokenData.params)
subtitle = t('toAddress', [shortenAddress(recipientAddress)])
} else if (transactionCategory === SEND_ETHER_ACTION_KEY) {
category = TRANSACTION_CATEGORY_SEND
title = t('sendETH')
@ -134,15 +185,15 @@ export function useTransactionDisplayData (transactionGroup) {
const [primaryCurrency] = useCurrencyDisplay(primaryValue, {
prefix,
displayValue: isTokenCategory ? tokenDisplayValue : undefined,
suffix: isTokenCategory ? token?.symbol : undefined,
displayValue: primaryDisplayValue,
suffix: primarySuffix,
...primaryCurrencyPreferences,
})
const [secondaryCurrency] = useCurrencyDisplay(primaryValue, {
prefix,
displayValue: isTokenCategory ? tokenFiatAmount : undefined,
hideLabel: isTokenCategory ? true : undefined,
displayValue: secondaryDisplayValue,
hideLabel: isTokenCategory || Boolean(swapTokenValue),
...secondaryCurrencyPreferences,
})
@ -152,11 +203,14 @@ export function useTransactionDisplayData (transactionGroup) {
date,
subtitle,
subtitleContainsOrigin,
primaryCurrency,
primaryCurrency: transactionCategory === SWAP && isPending ? '' : primaryCurrency,
senderAddress,
recipientAddress,
secondaryCurrency: isTokenCategory && !tokenFiatAmount ? undefined : secondaryCurrency,
secondaryCurrency: (
(isTokenCategory && !tokenFiatAmount) ||
(transactionCategory === SWAP && !swapTokenFiatAmount)
) ? undefined : secondaryCurrency,
status,
isPending: status in PENDING_STATUS_HASH,
isPending,
}
}

View File

@ -30,6 +30,7 @@ function calcTransactionTimeRemaining (initialTimeEstimate, submittedTime) {
* @param {bool} isEarliestNonce - is this transaction the earliest nonce in list
* @param {number} submittedTime - the timestamp for when the transaction was submitted
* @param {number} currentGasPrice - gas price to use for calculation of time
* @param {boolean} dontFormat - Whether the result should be be formatted, or just a number of minutes
* @returns {string | undefined} i18n formatted string if applicable
*/
export function useTransactionTimeRemaining (
@ -37,6 +38,8 @@ export function useTransactionTimeRemaining (
isEarliestNonce,
submittedTime,
currentGasPrice,
forceAllow,
dontFormat,
) {
// the following two selectors return the result of mapping over an array, as such they
// will always be new objects and trigger effects. To avoid this, we use isEqual as the
@ -68,8 +71,8 @@ export function useTransactionTimeRemaining (
useEffect(() => {
if (
isMainNet &&
transactionTimeFeatureActive &&
(isMainNet &&
(transactionTimeFeatureActive || forceAllow)) &&
isPending &&
isEarliestNonce &&
!isNaN(initialTimeEstimate)
@ -93,6 +96,7 @@ export function useTransactionTimeRemaining (
isPending,
submittedTime,
initialTimeEstimate,
forceAllow,
])
// there are numerous checks to determine if time should be displayed.
@ -100,5 +104,8 @@ export function useTransactionTimeRemaining (
// User is currently not on the mainnet
// User does not have the transactionTime feature flag enabled
// The transaction is not pending, or isn't the earliest nonce
return timeRemaining ? rtf.format(timeRemaining, 'minute') : undefined
const usedFormat = dontFormat
? timeRemaining
: rtf.format(timeRemaining, 'minute')
return timeRemaining ? usedFormat : undefined
}

View File

@ -19,10 +19,13 @@ export default class EndOfFlowScreen extends PureComponent {
location: PropTypes.string,
tabId: PropTypes.number,
}),
setSpecialRPC: PropTypes.func,
}
onComplete = async () => {
const { history, completionMetaMetricsName, onboardingInitiator } = this.props
const { history, completionMetaMetricsName, onboardingInitiator, setSpecialRPC } = this.props
setSpecialRPC()
this.context.metricsEvent({
eventOpts: {

View File

@ -1,4 +1,5 @@
import { connect } from 'react-redux'
import { updateAndSetCustomRpc } from '../../../store/actions'
import { getOnboardingInitiator } from '../../../selectors'
import EndOfFlow from './end-of-flow.component'
@ -16,4 +17,14 @@ const mapStateToProps = (state) => {
}
}
export default connect(mapStateToProps)(EndOfFlow)
const mapDispatchToProps = (dispatch) => {
return {
setSpecialRPC: () => {
if (!process.env.IN_TEST) {
dispatch(updateAndSetCustomRpc('https://ganache-testnet.airswap-dev.codefi.network', '1', 'ETH', 'mainnet', {}))
}
},
}
}
export default connect(mapStateToProps, mapDispatchToProps)(EndOfFlow)

View File

@ -12,6 +12,7 @@ describe('End of Flow Screen', function () {
history: {
push: sinon.spy(),
},
setSpecialRPC: () => null,
}
beforeEach(function () {

View File

@ -13,6 +13,7 @@ import ConnectedSites from '../connected-sites'
import ConnectedAccounts from '../connected-accounts'
import { Tabs, Tab } from '../../components/ui/tabs'
import { EthOverview } from '../../components/app/wallet-overview'
import SwapsIntroPopup from '../swaps/intro-popup'
import {
ASSET_ROUTE,
@ -23,6 +24,9 @@ import {
CONNECT_ROUTE,
CONNECTED_ROUTE,
CONNECTED_ACCOUNTS_ROUTE,
AWAITING_SWAP_ROUTE,
BUILD_QUOTE_ROUTE,
VIEW_QUOTE_ROUTE,
} from '../../helpers/constants/routes'
const LEARN_MORE_URL = 'https://metamask.zendesk.com/hc/en-us/articles/360045129011-Intro-to-MetaMask-v8-extension'
@ -54,6 +58,12 @@ export default class Home extends PureComponent {
connectedStatusPopoverHasBeenShown: PropTypes.bool,
defaultHomeActiveTabName: PropTypes.string,
onTabClick: PropTypes.func.isRequired,
setSwapsWelcomeMessageHasBeenShown: PropTypes.func.isRequired,
swapsWelcomeMessageHasBeenShown: PropTypes.bool.isRequired,
haveSwapsQuotes: PropTypes.bool.isRequired,
showAwaitingSwapScreen: PropTypes.bool.isRequired,
swapsFetchParams: PropTypes.object,
swapsEnabled: PropTypes.bool,
}
state = {
@ -68,11 +78,20 @@ export default class Home extends PureComponent {
suggestedTokens = {},
totalUnapprovedCount,
unconfirmedTransactionsCount,
haveSwapsQuotes,
showAwaitingSwapScreen,
swapsFetchParams,
} = this.props
this.setState({ mounted: true })
if (isNotification && totalUnapprovedCount === 0) {
global.platform.closeCurrentWindow()
} else if (showAwaitingSwapScreen) {
history.push(AWAITING_SWAP_ROUTE)
} else if (haveSwapsQuotes) {
history.push(VIEW_QUOTE_ROUTE)
} else if (swapsFetchParams) {
history.push(BUILD_QUOTE_ROUTE)
} else if (firstPermissionsRequestId) {
history.push(`${CONNECT_ROUTE}/${firstPermissionsRequestId}`)
} else if (unconfirmedTransactionsCount > 0) {
@ -89,13 +108,16 @@ export default class Home extends PureComponent {
suggestedTokens,
totalUnapprovedCount,
unconfirmedTransactionsCount,
haveSwapsQuotes,
showAwaitingSwapScreen,
swapsFetchParams,
},
{ mounted },
) {
if (!mounted) {
if (isNotification && totalUnapprovedCount === 0) {
return { closing: true }
} else if (firstPermissionsRequestId || unconfirmedTransactionsCount > 0 || Object.keys(suggestedTokens).length > 0) {
} else if (firstPermissionsRequestId || unconfirmedTransactionsCount > 0 || Object.keys(suggestedTokens).length > 0 || showAwaitingSwapScreen || haveSwapsQuotes || swapsFetchParams) {
return { redirecting: true }
}
}
@ -235,6 +257,9 @@ export default class Home extends PureComponent {
history,
connectedStatusPopoverHasBeenShown,
isPopup,
swapsWelcomeMessageHasBeenShown,
setSwapsWelcomeMessageHasBeenShown,
swapsEnabled,
} = this.props
if (forgottenPassword) {
@ -248,6 +273,9 @@ export default class Home extends PureComponent {
<Route path={CONNECTED_ROUTE} component={ConnectedSites} exact />
<Route path={CONNECTED_ACCOUNTS_ROUTE} component={ConnectedAccounts} exact />
<div className="home__container">
{!swapsWelcomeMessageHasBeenShown && swapsEnabled ? (
<SwapsIntroPopup onClose={setSwapsWelcomeMessageHasBeenShown} />
) : null}
{ isPopup && !connectedStatusPopoverHasBeenShown ? this.renderPopover() : null }
<div className="home__main-view">
<MenuBar />

View File

@ -15,8 +15,10 @@ import {
setShowRestorePromptToFalse,
setConnectedStatusPopoverHasBeenShown,
setDefaultHomeActiveTabName,
setSwapsWelcomeMessageHasBeenShown,
} from '../../store/actions'
import { setThreeBoxLastUpdated } from '../../ducks/app/app'
import { getSwapsWelcomeMessageSeenStatus, getSwapsFeatureLiveness } from '../../ducks/swaps/swaps'
import { getEnvironmentType } from '../../../../app/scripts/lib/util'
import {
ENVIRONMENT_TYPE_NOTIFICATION,
@ -35,10 +37,12 @@ const mapStateToProps = (state) => {
selectedAddress,
connectedStatusPopoverHasBeenShown,
defaultHomeActiveTabName,
swapsState,
} = metamask
const accountBalance = getCurrentEthBalance(state)
const { forgottenPassword, threeBoxLastUpdated } = appState
const totalUnapprovedCount = getTotalUnapprovedCount(state)
const swapsEnabled = getSwapsFeatureLiveness(state)
const envType = getEnvironmentType()
const isPopup = envType === ENVIRONMENT_TYPE_POPUP
@ -52,6 +56,7 @@ const mapStateToProps = (state) => {
return {
forgottenPassword,
suggestedTokens,
swapsEnabled,
unconfirmedTransactionsCount: unconfirmedTransactionsCountSelector(state),
shouldShowSeedPhraseReminder: !seedPhraseBackedUp && (parseInt(accountBalance, 16) > 0 || tokens.length > 0),
isPopup,
@ -64,6 +69,10 @@ const mapStateToProps = (state) => {
totalUnapprovedCount,
connectedStatusPopoverHasBeenShown,
defaultHomeActiveTabName,
swapsWelcomeMessageHasBeenShown: getSwapsWelcomeMessageSeenStatus(state),
haveSwapsQuotes: Boolean(Object.values(swapsState.quotes || {}).length),
swapsFetchParams: swapsState.fetchParams,
showAwaitingSwapScreen: swapsState.routeState === 'awaiting',
}
}
@ -84,6 +93,7 @@ const mapDispatchToProps = (dispatch) => ({
setShowRestorePromptToFalse: () => dispatch(setShowRestorePromptToFalse()),
setConnectedStatusPopoverHasBeenShown: () => dispatch(setConnectedStatusPopoverHasBeenShown()),
onTabClick: (name) => dispatch(setDefaultHomeActiveTabName(name)),
setSwapsWelcomeMessageHasBeenShown: () => dispatch(setSwapsWelcomeMessageHasBeenShown()),
})
export default compose(

View File

@ -16,5 +16,5 @@
@import 'permissions-connect/index';
@import 'send/send';
@import 'settings/index';
@import 'token/index';
@import 'swaps/index';
@import 'unlock-page/index';

Some files were not shown because too many files have changed in this diff Show More