diff --git a/apps/block_scout_web/assets/css/components/stakes/_stakes.scss b/apps/block_scout_web/assets/css/components/stakes/_stakes.scss index a01e58efea..dbde40d2b2 100644 --- a/apps/block_scout_web/assets/css/components/stakes/_stakes.scss +++ b/apps/block_scout_web/assets/css/components/stakes/_stakes.scss @@ -171,7 +171,7 @@ $stakes-stats-item-border-color: #fff !default; @include media-breakpoint-down(md) { grid-column-start: 2; grid-row-start: 2; - justify-self: end; + justify-self: left; } @include media-breakpoint-down(sm) { diff --git a/apps/block_scout_web/assets/js/lib/modals.js b/apps/block_scout_web/assets/js/lib/modals.js index 83c2b3b2d1..dfd79c7a14 100644 --- a/apps/block_scout_web/assets/js/lib/modals.js +++ b/apps/block_scout_web/assets/js/lib/modals.js @@ -25,6 +25,10 @@ export function currentModal () { return $currentModal } +export function isModalLocked() { + return modalLocked +} + export function openModal ($modal, unclosable) { // Hide all tooltips before showing a modal, // since they are sticking on top of modal diff --git a/apps/block_scout_web/assets/js/lib/validation.js b/apps/block_scout_web/assets/js/lib/validation.js index 5574925f74..c128cf2200 100644 --- a/apps/block_scout_web/assets/js/lib/validation.js +++ b/apps/block_scout_web/assets/js/lib/validation.js @@ -49,14 +49,14 @@ function updateSubmit ($submit, errors) { $submit.prop('disabled', !$.isEmptyObject(errors)) } -function displayInputError ($input, message) { +export function displayInputError ($input, message) { const group = $input.parent('.input-group') group.addClass('input-status-error') group.find('.input-group-message').html(message) } -function hideInputError ($input) { +export function hideInputError ($input) { const group = $input.parent('.input-group') group.removeClass('input-status-error') diff --git a/apps/block_scout_web/assets/js/pages/stakes/claim_reward.js b/apps/block_scout_web/assets/js/pages/stakes/claim_reward.js index 18b0dbc759..38a0fb2a36 100644 --- a/apps/block_scout_web/assets/js/pages/stakes/claim_reward.js +++ b/apps/block_scout_web/assets/js/pages/stakes/claim_reward.js @@ -1,5 +1,6 @@ import $ from 'jquery' -import { openModal, openErrorModal, openWarningModal, lockModal, unlockModal } from '../../lib/modals' +import { isModalLocked, lockModal, openErrorModal, openModal, unlockModal } from '../../lib/modals' +import { displayInputError, hideInputError } from '../../lib/validation' import { isSupportedNetwork } from './utils' export function openClaimRewardModal(store) { @@ -12,52 +13,75 @@ export function openClaimRewardModal(store) { const $modal = $(msg.html) const $closeButton = $modal.find('.close-modal') const $modalBody = $('.modal-body', $modal) - const $waitingMessageContainer = $modalBody.find('p') - let dotCounter = 0 - const dotCounterInterval = setInterval(() => { - let waitingMessage = $.trim($waitingMessageContainer.text()) - if (!waitingMessage.endsWith('.')) { - waitingMessage = waitingMessage + '.' - } - waitingMessage = waitingMessage.replace(/\.+$/g, " " + ".".repeat(dotCounter)) - $waitingMessageContainer.text(waitingMessage) - dotCounter = (dotCounter + 1) % 4 - }, 500) - - $closeButton.hide() - lockModal($modal) - channel.on('claim_reward_pools', msg_pools => { - channel.off('claim_reward_pools') - $closeButton.show() - unlockModal($modal) - clearInterval(dotCounterInterval) + const dotCounterInterval = poolsSearchingStarted() + + const ref = channel.on('claim_reward_pools', msg_pools => { $modalBody.html(msg_pools.html) - onPoolsFound($modal, $modalBody) + poolsSearchingFinished() }) $modal.on('shown.bs.modal', () => { - channel.push('render_claim_reward', {}).receive('error', (error) => { - openErrorModal('Claim Reward', error.reason) + channel.push('render_claim_reward', { + }).receive('error', (error) => { + poolsSearchingFinished(error.reason) + }).receive('timeout', () => { + poolsSearchingFinished('Connection timeout') }) }) $modal.on('hidden.bs.modal', () => { $modal.remove() }) + function poolsSearchingStarted() { + $closeButton.hide() + lockModal($modal) + + const $waitingMessageContainer = $modalBody.find('p') + let dotCounter = 0 + + return setInterval(() => { + let waitingMessage = $.trim($waitingMessageContainer.text()) + if (!waitingMessage.endsWith('.')) { + waitingMessage = waitingMessage + '.' + } + waitingMessage = waitingMessage.replace(/\.+$/g, " " + ".".repeat(dotCounter)) + $waitingMessageContainer.text(waitingMessage) + dotCounter = (dotCounter + 1) % 4 + }, 500) + } + function poolsSearchingFinished(error) { + channel.off('claim_reward_pools', ref) + $closeButton.show() + unlockModal($modal) + clearInterval(dotCounterInterval) + if (error) { + openErrorModal('Claim Reward', error) + } else { + onPoolsFound($modal, $modalBody, channel) + } + } openModal($modal); }).receive('error', (error) => { openErrorModal('Claim Reward', error.reason) + }).receive('timeout', () => { + openErrorModal('Claim Reward', 'Connection timeout') }) } -function onPoolsFound($modal, $modalBody) { - const $poolsDropdown = $('[pool-select]', $modalBody) +function onPoolsFound($modal, $modalBody, channel) { + const $poolsDropdown = $('select', $modalBody) const $epochChoiceRadio = $('input[name="epoch_choice"]', $modalBody) - const $specifiedEpochsText = $('.specified-epochs', $modalBody) + const $specifiedEpochsText = $('input.specified-epochs', $modalBody) + const $recalculateButton = $('button.recalculate', $modalBody) let allowedEpochs = [] $poolsDropdown.on('change', () => { + if (isModalLocked()) return false + const data = $('option:selected', this).data() + const tokenRewardSum = data.tokenRewardSum ? data.tokenRewardSum : '0' + const nativeRewardSum = data.nativeRewardSum ? data.nativeRewardSum : '0' + const gasLimit = data.gasLimit ? data.gasLimit : '0' const $poolInfo = $('.selected-pool-info', $modalBody) const epochs = data.epochs ? data.epochs : '' @@ -65,19 +89,22 @@ function onPoolsFound($modal, $modalBody) { $poolsDropdown.blur() $('textarea', $poolInfo).val(epochs) - $('#token-reward-sum', $poolInfo).html(data.tokenRewardSum ? data.tokenRewardSum : '0') - $('#native-reward-sum', $poolInfo).html(data.nativeRewardSum ? data.nativeRewardSum : '0') - $('#tx-gas-limit', $poolInfo).html(data.gasLimit ? '~' + data.gasLimit : '0') + $('#token-reward-sum', $poolInfo).html(tokenRewardSum).data('default', tokenRewardSum) + $('#native-reward-sum', $poolInfo).html(nativeRewardSum).data('default', nativeRewardSum) + $('#tx-gas-limit', $poolInfo).html('~' + gasLimit).data('default', gasLimit) $('#epoch-choice-all', $poolInfo).click() $specifiedEpochsText.val('') $poolInfo.removeClass('hidden') $('.modal-bottom-disclaimer', $modal).removeClass('hidden') + hideInputError($recalculateButton) }) $epochChoiceRadio.on('change', () => { + if (isModalLocked()) return false if ($('#epoch-choice-all', $modalBody).is(':checked')) { $specifiedEpochsText.addClass('hidden') - showRecalcButton(false, $modalBody) + showButton('submit', $modalBody) + hideInputError($recalculateButton) } else { $specifiedEpochsText.removeClass('hidden') $specifiedEpochsText.trigger('input') @@ -85,26 +112,99 @@ function onPoolsFound($modal, $modalBody) { }) $specifiedEpochsText.on('input', () => { + if (isModalLocked()) return false + const filtered = filterSpecifiedEpochs($specifiedEpochsText.val()) - const pointedEpochs = expandEpochsToArray(filtered) - const needsRecalc = pointedEpochs.length > 0 && !isArrayIncludedToArray(allowedEpochs, pointedEpochs) - showRecalcButton(needsRecalc, $modalBody) $specifiedEpochsText.val(filtered) + + const pointedEpochs = expandEpochsToArray(filtered) + const pointedEpochsAllowed = pointedEpochs.filter(item => allowedEpochs.indexOf(item) != -1) + + const needsRecalc = pointedEpochs.length > 0 && pointedEpochsAllowed.length != allowedEpochs.length + showButton(needsRecalc ? 'recalculate' : 'submit', $modalBody) + + if (needsRecalc && pointedEpochsAllowed.length == 0) { + $recalculateButton.prop('disabled', true) + displayInputError($recalculateButton, 'The specified staking epochs are not in the allowed range') + } else { + $recalculateButton.prop('disabled', false) + hideInputError($recalculateButton) + } + }) + + $recalculateButton.on('click', (e) => { + if (isModalLocked()) return false + e.preventDefault() + recalcStarted() + + const specifiedEpochs = $specifiedEpochsText.val().replace(/[-|,]$/g, '').trim() + $specifiedEpochsText.val(specifiedEpochs) + + const epochs = expandEpochsToArray(specifiedEpochs).filter(item => allowedEpochs.indexOf(item) != -1) + const poolStakingAddress = $poolsDropdown.val() + const ref = channel.on('claim_reward_recalculations', result => { + recalcFinished(result) + }) + channel.push('recalc_claim_reward', { + epochs, + pool_staking_address: poolStakingAddress + }).receive('error', (error) => { + recalcFinished({error: error.reason}) + }).receive('timeout', () => { + recalcFinished({error: 'Connection timeout'}) + }) + function recalcStarted() { + hideInputError($recalculateButton) + lockUI(true, $modal, $recalculateButton, $poolsDropdown, $epochChoiceRadio, $specifiedEpochsText); + } + function recalcFinished(result) { + channel.off('claim_reward_recalculations', ref) + if (result.error) { + displayInputError($recalculateButton, result.error) + } else { + showButton('submit', $modalBody, result) + } + lockUI(false, $modal, $recalculateButton, $poolsDropdown, $epochChoiceRadio, $specifiedEpochsText); + } }) } -function showRecalcButton(show, $modalBody) { - const $itemsToStrikeOut = $('#token-reward-sum, #native-reward-sum, #tx-gas-limit', $modalBody) +function lockUI(lock, $modal, $button, $poolsDropdown, $epochChoiceRadio, $specifiedEpochsText) { + if (lock) { + lockModal($modal, $button) + } else { + unlockModal($modal, $button) + } + $poolsDropdown.prop('disabled', lock) + $epochChoiceRadio.prop('disabled', lock) + $specifiedEpochsText.prop('disabled', lock) +} + +function showButton(type, $modalBody, calculations) { const $recalculateButton = $('button.recalculate', $modalBody) const $submitButton = $('button.submit', $modalBody) - if (show) { - $itemsToStrikeOut.css('text-decoration', 'line-through') - $recalculateButton.removeClass('hidden') - $submitButton.addClass('hidden') - } else { - $itemsToStrikeOut.css('text-decoration', '') + + const $tokenRewardSum = $('#token-reward-sum', $modalBody) + const $nativeRewardSum = $('#native-reward-sum', $modalBody) + const $gasLimit = $('#tx-gas-limit', $modalBody) + + if (type == 'submit') { $recalculateButton.addClass('hidden') $submitButton.removeClass('hidden') + + const tokenRewardSum = !calculations ? $tokenRewardSum.data('default') : calculations.token_reward_sum + const nativeRewardSum = !calculations ? $nativeRewardSum.data('default') : calculations.native_reward_sum + const gasLimit = !calculations ? $gasLimit.data('default') : calculations.gas_limit + + $tokenRewardSum.text(tokenRewardSum).css('text-decoration', '') + $nativeRewardSum.text(nativeRewardSum).css('text-decoration', '') + $gasLimit.text('~' + gasLimit).css('text-decoration', '') + } else { + $recalculateButton.removeClass('hidden') + $submitButton.addClass('hidden'); + [$tokenRewardSum, $nativeRewardSum, $gasLimit].forEach( + $item => $item.css('text-decoration', 'line-through') + ) } } @@ -147,8 +247,3 @@ function filterSpecifiedEpochs(epochs) { filtered = filtered.replace(/^[,|-|0]/g, '') return filtered } - -function isArrayIncludedToArray(source, target) { - const filtered = target.filter(item => source.indexOf(item) != -1) - return filtered.length == source.length -} diff --git a/apps/block_scout_web/lib/block_scout_web/channels/stakes_channel.ex b/apps/block_scout_web/lib/block_scout_web/channels/stakes_channel.ex index b37d144c2c..77e404ee22 100644 --- a/apps/block_scout_web/lib/block_scout_web/channels/stakes_channel.ex +++ b/apps/block_scout_web/lib/block_scout_web/channels/stakes_channel.ex @@ -4,7 +4,7 @@ defmodule BlockScoutWeb.StakesChannel do """ use BlockScoutWeb, :channel - alias BlockScoutWeb.{StakesController, StakesView} + alias BlockScoutWeb.{StakesController, StakesHelpers, StakesView} alias Explorer.Chain alias Explorer.Chain.Cache.BlockNumber alias Explorer.Chain.Token @@ -14,7 +14,7 @@ defmodule BlockScoutWeb.StakesChannel do import BlockScoutWeb.Gettext - @searching_claim_reward_pools :searching_claim_reward_pools + @claim_reward_long_op :claim_reward_long_op intercept(["staking_update"]) @@ -23,9 +23,9 @@ defmodule BlockScoutWeb.StakesChannel do end def terminate(_, socket) do - s = socket.assigns[@searching_claim_reward_pools] + s = socket.assigns[@claim_reward_long_op] if s != nil do - :ets.delete(ContractState, searching_claim_reward_pools_key(s.staker)) + :ets.delete(ContractState, claim_reward_long_op_key(s.staker)) end end @@ -244,19 +244,10 @@ defmodule BlockScoutWeb.StakesChannel do def handle_in("render_claim_reward", data, socket) do staker = socket.assigns[:account] - - search_in_progress = if socket.assigns[@searching_claim_reward_pools] do - true - else - with [{_, true}] <- :ets.lookup(ContractState, searching_claim_reward_pools_key(staker)) do - true - end - end - staking_contract_address = try do ContractState.get(:staking_contract).address after end cond do - search_in_progress == true -> + claim_reward_long_op_active(socket) == true -> {:reply, {:error, %{reason: gettext("Pools searching is already in progress for this address")}}, socket} staker == nil || staker == "" || staker == "0x0000000000000000000000000000000000000000" -> {:reply, {:error, %{reason: gettext("Unknown staker address. Please, choose your account in MetaMask")}}, socket} @@ -272,7 +263,7 @@ defmodule BlockScoutWeb.StakesChannel do task = Task.async(__MODULE__, :find_claim_reward_pools, [socket, staker, staking_contract_address]) %{ html: "OK", - socket: assign(socket, @searching_claim_reward_pools, %{task: task, staker: staker}) + socket: assign(socket, @claim_reward_long_op, %{task: task, staker: staker}) } end @@ -280,6 +271,30 @@ defmodule BlockScoutWeb.StakesChannel do end end + def handle_in("recalc_claim_reward", data, socket) do + epochs = data["epochs"] + pool_staking_address = data["pool_staking_address"] + staker = socket.assigns[:account] + staking_contract_address = try do ContractState.get(:staking_contract).address after end + + cond do + claim_reward_long_op_active(socket) == true -> + {:reply, {:error, %{reason: gettext("Reward calculating is already in progress for this address")}}, socket} + Enum.count(epochs) == 0 -> + {:reply, {:error, %{reason: gettext("Staking epochs are not specified or not in the allowed range")}}, socket} + pool_staking_address == nil || pool_staking_address == "" || pool_staking_address == "0x0000000000000000000000000000000000000000" -> + {:reply, {:error, %{reason: gettext("Unknown pool staking address. Please, contact support")}}, socket} + staker == nil || staker == "" || staker == "0x0000000000000000000000000000000000000000" -> + {:reply, {:error, %{reason: gettext("Unknown staker address. Please, choose your account in MetaMask")}}, socket} + staking_contract_address == nil || staking_contract_address == "" || staking_contract_address == "0x0000000000000000000000000000000000000000" -> + {:reply, {:error, %{reason: gettext("Unknown address of Staking contract. Please, contact support")}}, socket} + true -> + task = Task.async(__MODULE__, :recalc_claim_reward, [socket, staking_contract_address, epochs, pool_staking_address, staker]) + socket = assign(socket, @claim_reward_long_op, %{task: task, staker: staker}) + {:reply, {:ok, %{html: "OK"}}, socket} + end + end + def handle_in("render_claim_withdrawal", %{"address" => staking_address}, socket) do pool = Chain.staking_pool(staking_address) token = ContractState.get(:token) @@ -302,10 +317,10 @@ defmodule BlockScoutWeb.StakesChannel do end def handle_info({:DOWN, ref, :process, pid, _reason}, socket) do - s = socket.assigns[@searching_claim_reward_pools] + s = socket.assigns[@claim_reward_long_op] socket = if s && s.task.ref == ref && s.task.pid == pid do - :ets.delete(ContractState, searching_claim_reward_pools_key(s.staker)) - assign(socket, @searching_claim_reward_pools, nil) + :ets.delete(ContractState, claim_reward_long_op_key(s.staker)) + assign(socket, @claim_reward_long_op, nil) else socket end @@ -330,7 +345,7 @@ defmodule BlockScoutWeb.StakesChannel do end def find_claim_reward_pools(socket, staker, staking_contract_address) do - :ets.insert(ContractState, {searching_claim_reward_pools_key(staker), true}) + :ets.insert(ContractState, {claim_reward_long_op_key(staker), true}) try do staker_padded = address_pad_to_64(staker) json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) @@ -451,7 +466,71 @@ defmodule BlockScoutWeb.StakesChannel do html: html }) after - :ets.delete(ContractState, searching_claim_reward_pools_key(staker)) + :ets.delete(ContractState, claim_reward_long_op_key(staker)) + end + end + + def recalc_claim_reward(socket, staking_contract_address, epochs, pool_staking_address, staker) do + :ets.insert(ContractState, {claim_reward_long_op_key(staker), true}) + try do + json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) + + amounts_result = ContractReader.call_get_reward_amount( + staking_contract_address, + epochs, + pool_staking_address, + staker, + json_rpc_named_arguments + ) + + {error, amounts} = case amounts_result do + {:ok, amounts} -> + {nil, amounts} + {:error, reason} -> + {error_reason_to_string(reason), %{token_reward_sum: 0, native_reward_sum: 0}} + end + + {error, gas_limit} = if error == nil do + estimate_gas_result = ContractReader.claim_reward_estimate_gas( + staking_contract_address, + epochs, + pool_staking_address, + staker, + json_rpc_named_arguments + ) + + case estimate_gas_result do + {:ok, gas_limit} -> + {nil, gas_limit} + {:error, reason} -> + {error_reason_to_string(reason), 0} + end + else + {error, 0} + end + + token = ContractState.get(:token) + coin = %Token{symbol: Explorer.coin(), decimals: Decimal.new(18)} + + push(socket, "claim_reward_recalculations", %{ + token_reward_sum: StakesHelpers.format_token_amount(amounts.token_reward_sum, token, digits: 5, ellipsize: false, symbol: false), + native_reward_sum: StakesHelpers.format_token_amount(amounts.native_reward_sum, coin, digits: 5, ellipsize: false, symbol: false), + gas_limit: gas_limit, + error: error + }) + after + :ets.delete(ContractState, claim_reward_long_op_key(staker)) + end + end + + defp claim_reward_long_op_active(socket) do + if socket.assigns[@claim_reward_long_op] do + true + else + staker = socket.assigns[:account] + with [{_, true}] <- :ets.lookup(ContractState, claim_reward_long_op_key(staker)) do + true + end end end @@ -536,9 +615,9 @@ defmodule BlockScoutWeb.StakesChannel do end end - defp searching_claim_reward_pools_key(staker) do + defp claim_reward_long_op_key(staker) do staker = if staker == nil, do: "", else: staker - Atom.to_string(@searching_claim_reward_pools) <> "_" <> staker + Atom.to_string(@claim_reward_long_op) <> "_" <> staker end defp truncate_address("0x000000000000000000000000" <> truncated_address) do diff --git a/apps/block_scout_web/lib/block_scout_web/templates/stakes/_stakes_btn_claim_reward.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/stakes/_stakes_btn_claim_reward.html.eex index a36547d971..4d5a672022 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/stakes/_stakes_btn_claim_reward.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/stakes/_stakes_btn_claim_reward.html.eex @@ -1,4 +1,4 @@ - \ No newline at end of file diff --git a/apps/block_scout_web/lib/block_scout_web/templates/stakes/_stakes_modal_claim_reward_content.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/stakes/_stakes_modal_claim_reward_content.html.eex index 1643b5da1c..4b7c48d008 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/stakes/_stakes_modal_claim_reward_content.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/stakes/_stakes_modal_claim_reward_content.html.eex @@ -2,8 +2,8 @@

<%= gettext("We found the following pools you can claim reward from:") %>

- + <%= for {pool_staking_address, data} <- @pools do %> <% token_reward_sum = format_token_amount(data.token_reward_sum, @token, digits: 5, ellipsize: false, symbol: false) @@ -47,8 +47,11 @@

- <%= render BlockScoutWeb.StakesView, "_stakes_btn_recalculate.html", text: gettext("Recalculate"), extra_class: "full-width recalculate hidden" %> - <%= render BlockScoutWeb.StakesView, "_stakes_btn_withdraw.html", text: gettext("Claim Reward"), extra_class: "full-width submit" %> +
+ <%= render BlockScoutWeb.StakesView, "_stakes_btn_recalculate.html", text: gettext("Recalculate"), extra_class: "full-width recalculate hidden" %> + <%= render BlockScoutWeb.StakesView, "_stakes_btn_withdraw.html", text: gettext("Claim Reward"), extra_class: "full-width submit" %> +
+
<% else %> diff --git a/apps/block_scout_web/lib/block_scout_web/templates/stakes/_stakes_top.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/stakes/_stakes_top.html.eex index 0bb2a893bc..d6b1937169 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/stakes/_stakes_top.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/stakes/_stakes_top.html.eex @@ -14,7 +14,7 @@ <% end %> <% else %> <%= - button_class = + button_class = "full-width " <> if @account[:pool] do "js-make-stake" else @@ -29,7 +29,7 @@ <% end %> <% end %> - <%= render BlockScoutWeb.StakesView, "_stakes_btn_claim_reward.html", text: gettext("Claim Reward") %> + <%= render BlockScoutWeb.StakesView, "_stakes_btn_claim_reward.html", text: gettext("Claim Reward"), extra_class: "full-width" %>