Implement Recalculate button for Claim Reward

staking
Vadim 5 years ago committed by Victor Baranov
parent 5676012a37
commit 868e43156b
  1. 2
      apps/block_scout_web/assets/css/components/stakes/_stakes.scss
  2. 4
      apps/block_scout_web/assets/js/lib/modals.js
  3. 4
      apps/block_scout_web/assets/js/lib/validation.js
  4. 187
      apps/block_scout_web/assets/js/pages/stakes/claim_reward.js
  5. 123
      apps/block_scout_web/lib/block_scout_web/channels/stakes_channel.ex
  6. 2
      apps/block_scout_web/lib/block_scout_web/templates/stakes/_stakes_btn_claim_reward.html.eex
  7. 10
      apps/block_scout_web/lib/block_scout_web/templates/stakes/_stakes_btn_recalculate.html.eex
  8. 11
      apps/block_scout_web/lib/block_scout_web/templates/stakes/_stakes_modal_claim_reward_content.html.eex
  9. 4
      apps/block_scout_web/lib/block_scout_web/templates/stakes/_stakes_top.html.eex

@ -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) {

@ -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

@ -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')

@ -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
}

@ -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

@ -1,4 +1,4 @@
<button class="btn-full-primary js-claim-reward">
<button class="btn-full-primary js-claim-reward <%= if assigns[:extra_class] do @extra_class end %>">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<path fill-rule="evenodd" d="M15 16H1a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1zm-1-3H2v1h12v-1zm-3.754-8.279L9 3.479V8a1 1 0 0 1-2 0V3.489l-1.235 1.23a1.042 1.042 0 0 1-1.469 0 1.032 1.032 0 0 1 0-1.464L7.235.326a1.041 1.041 0 0 1 1.137-.22c.007.003.012.01.019.013.144.049.28.122.394.236l2.921 2.911a1.027 1.027 0 0 1 0 1.455 1.034 1.034 0 0 1-1.46 0z"/>
</svg>

@ -19,16 +19,10 @@
width="16"
height="16"
>
<g id="Layer_3_copy_2">
<g fill="#555753">
<path
d="m32.5 4.999c-5.405 0-10.444 1.577-14.699 4.282l-5.75-5.75v16.11h16.11l-6.395-6.395c3.18-1.787 6.834-2.82 10.734-2.82 12.171 0 22.073 9.902 22.073 22.074 0 2.899-0.577 5.664-1.599 8.202l4.738 2.762c1.47-3.363 2.288-7.068 2.288-10.964 0-15.164-12.337-27.501-27.5-27.501z"
/>
<path
d="m43.227 51.746c-3.179 1.786-6.826 2.827-10.726 2.827-12.171 0-22.073-9.902-22.073-22.073 0-2.739 0.524-5.35 1.439-7.771l-4.731-2.851c-1.375 3.271-2.136 6.858-2.136 10.622 0 15.164 12.336 27.5 27.5 27.5 5.406 0 10.434-1.584 14.691-4.289l5.758 5.759v-16.112h-16.111l6.389 6.388z"
/>
<path d="m32.5 4.999c-5.405 0-10.444 1.577-14.699 4.282l-5.75-5.75v16.11h16.11l-6.395-6.395c3.18-1.787 6.834-2.82 10.734-2.82 12.171 0 22.073 9.902 22.073 22.074 0 2.899-0.577 5.664-1.599 8.202l4.738 2.762c1.47-3.363 2.288-7.068 2.288-10.964 0-15.164-12.337-27.501-27.5-27.501z" />
<path d="m43.227 51.746c-3.179 1.786-6.826 2.827-10.726 2.827-12.171 0-22.073-9.902-22.073-22.073 0-2.739 0.524-5.35 1.439-7.771l-4.731-2.851c-1.375 3.271-2.136 6.858-2.136 10.622 0 15.164 12.336 27.5 27.5 27.5 5.406 0 10.434-1.584 14.691-4.289l5.758 5.759v-16.112h-16.111l6.389 6.388z" />
</g>
</g>
</svg>
<span class="btn-full-primary-text"><%= @text %></span>
</button>

@ -2,8 +2,8 @@
<p class="form-p"><%= gettext("We found the following pools you can claim reward from:") %></p>
<form>
<div class="input-group form-group">
<select pool-select class="form-control">
<option disabled selected><%= gettext("Choose Pool") %></option>
<select class="form-control">
<option disabled="disabled" selected="selected"><%= gettext("Choose Pool") %></option>
<%= 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 @@
<span class="text-dark" id="tx-gas-limit"></span><br />
</p>
</div>
<%= 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" %>
<div class="input-group">
<%= 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" %>
<div class="input-group-message"></div>
</div>
</div>
</form>
<% else %>

@ -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" %>
</div>
</div>
</div>

Loading…
Cancel
Save