Skip to content

Commit

Permalink
feat: staking gas fee improvements (anoma#626)
Browse files Browse the repository at this point in the history
- Use minimum gas price instead of hardcoded price for bond, unbond and
  withdraw

- Account for fees in maximum valid amount in bonding

- Warn the user when bonding too much to allow for future unbonding and
  withdrawal

- Refresh balances when a transaction completes (specifically when the
  toasts atom changes)

- Add max button to new bonding form

- Display gas fee in bonding and unbonding

- Sum all staking positions when calculating total staked amount

- Allow BigNumber for AmountInput min and max props

- Minor styling fixes
  • Loading branch information
emccorson committed Feb 19, 2024
1 parent a1e5f18 commit 41aba14
Show file tree
Hide file tree
Showing 12 changed files with 467 additions and 267 deletions.
23 changes: 21 additions & 2 deletions apps/namada-interface/src/App/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable max-len */
import { AnimatePresence } from "framer-motion";
import { createBrowserHistory } from "history";
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
import { useEffect, useState } from "react";
import { PersistGate } from "redux-persist/integration/react";
import { ThemeProvider } from "styled-components";
Expand All @@ -22,7 +23,13 @@ import { Outlet } from "react-router-dom";
import { addAccounts, fetchBalances } from "slices/accounts";
import { setChain } from "slices/chain";
import { SettingsState } from "slices/settings";
import { persistor, store, useAppDispatch, useAppSelector } from "store";
import {
persistor,
reduxStoreAtom,
store,
useAppDispatch,
useAppSelector,
} from "store";
import {
AppContainer,
AppLoader,
Expand All @@ -34,9 +41,9 @@ import {
} from "./App.components";
import { TopNavigation } from "./TopNavigation";

import { useAtom, useSetAtom } from "jotai";
import { accountsAtom, balancesAtom } from "slices/accounts";
import { chainAtom } from "slices/chain";
import { minimumGasPriceAtom } from "slices/fees";

export const history = createBrowserHistory({ window });

Expand All @@ -58,6 +65,10 @@ export const AnimatedTransition = (props: {
);
};

// TODO: can be moved to slices/notifications once redux is removed
// Defining it there currently causes a unit test error related to redux-persist
const toastsAtom = atom((get) => get(reduxStoreAtom).notifications.toasts);

function App(): JSX.Element {
const dispatch = useAppDispatch();
const initialColorMode = loadColorMode();
Expand All @@ -71,6 +82,7 @@ function App(): JSX.Element {
const [chain, refreshChain] = useAtom(chainAtom);
const refreshAccounts = useSetAtom(accountsAtom);
const refreshBalances = useSetAtom(balancesAtom);
const refreshMinimumGasPrice = useSetAtom(minimumGasPriceAtom);

const { connectedChains } = useAppSelector<SettingsState>(
(state) => state.settings
Expand Down Expand Up @@ -101,6 +113,7 @@ function App(): JSX.Element {
connectedChains.includes(chain.id)
) {
fetchAccounts();
refreshMinimumGasPrice();
}
}, [chain]);

Expand All @@ -118,6 +131,12 @@ function App(): JSX.Element {
})();
}, [currentExtensionAttachStatus]);

const toasts = useAtomValue(toastsAtom);
useEffect(() => {
// TODO: this could be more conservative about how often it fetches balances
refreshBalances();
}, [toasts]);

return (
<ThemeProvider theme={theme}>
<PersistGate loading={null} persistor={persistor}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { useState } from "react";

import {
ActionButton,
Alert,
AmountInput,
Input,
Stack,
Text,
Expand All @@ -12,9 +14,8 @@ import {
StakingPosition,
} from "slices/StakingAndGovernance";
import { Account } from "slices/accounts";
import {
BondingAddressSelect,
} from "./NewBondingPosition.components";
import { GAS_LIMIT } from "slices/fees";
import { BondingAddressSelect } from "./NewBondingPosition.components";

const REMAINS_BONDED_KEY = "Remains bonded";

Expand All @@ -25,12 +26,20 @@ type Props = {
confirmBonding: (changeInStakingPosition: ChangeInStakingPosition) => void;
// called when the user cancels bonding
cancelBonding: () => void;
minimumGasPrice: BigNumber;
isRevealPkNeeded: (address: string) => boolean;
};

// contains everything what the user needs for bonding funds
export const NewBondingPosition = (props: Props): JSX.Element => {
const { accounts, currentBondingPositions, confirmBonding, cancelBonding } =
props;
const {
accounts,
currentBondingPositions,
confirmBonding,
cancelBonding,
minimumGasPrice,
isRevealPkNeeded,
} = props;

const selectOptions = accounts
.filter((acc) => !acc.details.isShielded)
Expand All @@ -43,15 +52,40 @@ export const NewBondingPosition = (props: Props): JSX.Element => {
const [memo, setMemo] = useState<string>();
const currentAddress = currentAccount?.details.address;

const currentBondingPosition = currentBondingPositions.find(
(pos) => pos.owner === currentAccount?.details.address
);
const stakedAmount: BigNumber = new BigNumber(
currentBondingPosition?.stakedAmount || "0"
);
const stakedAmount = currentBondingPositions
.filter((pos) => pos.owner === currentAddress)
.reduce((acc, current) => acc.plus(current.stakedAmount), new BigNumber(0));

const currentNAMBalance: BigNumber =
currentAccount.balance["NAM"] || new BigNumber(0);

// TODO: Expecting that these could be set by the user in the future
const gasPrice = minimumGasPrice;
const gasLimit = GAS_LIMIT;

const singleTransferFee = gasPrice.multipliedBy(gasLimit);

// gas fee for bonding tx and reveal PK tx (if needed)
const bondingGasFee = isRevealPkNeeded(currentAddress)
? singleTransferFee.multipliedBy(2)
: singleTransferFee;

// gas fee for bonding tx and reveal PK tx (if needed) plus expected unbond and
// withdraw gas fees in the future
const expectedTotalGasFee = bondingGasFee.plus(
singleTransferFee.multipliedBy(2)
);

const realAvailableBalance = BigNumber.maximum(
currentNAMBalance.minus(bondingGasFee),
0
);

const safeAvailableBalance = BigNumber.maximum(
currentNAMBalance.minus(expectedTotalGasFee),
0
);

const handleAddressChange = (
e: React.ChangeEvent<HTMLSelectElement>
): void => {
Expand All @@ -64,29 +98,21 @@ export const NewBondingPosition = (props: Props): JSX.Element => {
}
};

// storing the unbonding input value locally here as string
// we threat them as strings except below in validation
// might have to change later to numbers
const [amountToBond, setAmountToBond] = useState("");
const [amountToBond, setAmountToBond] = useState<BigNumber | undefined>();

// unbonding amount and displayed value with a very naive validation
// TODO (https://github.com/anoma/namada-interface/issues/4#issuecomment-1260564499)
// do proper validation as part of input
const amountToBondNumber = new BigNumber(amountToBond);
const showGasFeeWarning =
typeof amountToBond !== "undefined" &&
amountToBond.isGreaterThan(safeAvailableBalance) &&
amountToBond.isLessThanOrEqualTo(realAvailableBalance);

// if this is the case, we display error message
const isEntryIncorrect =
(amountToBond !== "" && amountToBondNumber.isLessThanOrEqualTo(0)) ||
amountToBondNumber.isGreaterThan(currentNAMBalance) ||
amountToBondNumber.isNaN();

// if incorrect or empty value we disable the button
const isEntryIncorrectOrEmpty = isEntryIncorrect || amountToBond === "";
const isFormInvalid =
typeof amountToBond === "undefined" || amountToBond.isEqualTo(0);

// we convey this with an object that can be used
const remainsBondedToDisplay = isEntryIncorrect
? `The bonding amount can be more than 0 and at most ${currentNAMBalance}`
: `${stakedAmount.plus(amountToBondNumber).toString()}`;
const remainsBondedToDisplay =
typeof amountToBond === "undefined"
? ""
: stakedAmount.plus(amountToBond).toString();

// data for the table
const bondingSummary = [
Expand All @@ -96,23 +122,32 @@ export const NewBondingPosition = (props: Props): JSX.Element => {
value: `${currentNAMBalance}`,
},
{
uuid: "2",
uuid: "1",
key: "Expected gas fee",
value: `${expectedTotalGasFee}`,
},
{
uuid: "3",
key: "Bonded amount",
value: String(stakedAmount),
},
{
uuid: "3",
uuid: "4",
key: "Amount to bond",
value: amountToBond,
value: amountToBond?.toString(),
hint: "stake",
},
{
uuid: "4",
uuid: "5",
key: REMAINS_BONDED_KEY,
value: remainsBondedToDisplay,
},
];

const handleMaxButtonClick = (): void => {
setAmountToBond(safeAvailableBalance);
};

return (
<div style={{ width: "100%", margin: "0 20px" }}>
{/* input field */}
Expand All @@ -124,45 +159,60 @@ export const NewBondingPosition = (props: Props): JSX.Element => {
onChange={handleAddressChange}
/>

<Input
type="text"
value={amountToBond}
<AmountInput
label="Amount"
onChange={(e) => {
setAmountToBond(e.target.value);
}}
value={amountToBond}
onChange={(e) => setAmountToBond(e.target.value)}
min={0}
max={realAvailableBalance}
/>

<Input type="text" value={memo} onChange={(e) => setMemo(e.target.value)} label="Memo" />
{showGasFeeWarning && (
<Alert type="warning" title="Warning!">
We recommend leaving at least {expectedTotalGasFee.toString()} in
your account to allow for future unbond and withdraw transactions.
</Alert>
)}

<ActionButton onClick={handleMaxButtonClick}>Max</ActionButton>

<Input
type="text"
value={memo}
onChange={(e) => setMemo(e.target.value)}
label="Memo"
/>

<div>
{bondingSummary.map(({ key, value }, i) => <Text style={{ color: "white" }} key={i}>{key}: {value}</Text>)}
{bondingSummary.map(({ key, value }, i) => (
<Text className="text-white" key={i}>
{key}: {value}
</Text>
))}
</div>

{/* confirmation and cancel */}
<ActionButton
onClick={() => {
if (typeof amountToBond === "undefined") {
return;
}

const changeInStakingPosition: ChangeInStakingPosition = {
amount: amountToBondNumber,
amount: amountToBond,
owner: currentAddress,
validatorId: currentBondingPositions[0].validatorId,
memo,
gasPrice,
gasLimit,
};
confirmBonding(changeInStakingPosition);
}}
disabled={isEntryIncorrectOrEmpty}
disabled={isFormInvalid}
>
Confirm
</ActionButton>
<ActionButton
onClick={() => {
cancelBonding();
}}
style={{ backgroundColor: "lightgrey", color: "black" }}
>
Cancel
</ActionButton>

<ActionButton onClick={cancelBonding}>Cancel</ActionButton>
</Stack>
</div>
);
Expand Down
Loading

0 comments on commit 41aba14

Please sign in to comment.