diff --git a/components/dataViews/TransactionInfo.tsx b/components/dataViews/TransactionInfo.tsx index 2abb112..0abb9ef 100644 --- a/components/dataViews/TransactionInfo.tsx +++ b/components/dataViews/TransactionInfo.tsx @@ -5,7 +5,7 @@ import { DbTransaction } from "../../types"; import { useAppContext } from "../../context/AppContext"; import HashView from "./HashView"; import StackableContainer from "../layout/StackableContainer"; -import { printableCoins } from "../../lib/displayHelpers"; +import { printableCoins, printableCoin } from "../../lib/displayHelpers"; interface Props { tx: DbTransaction; @@ -16,38 +16,88 @@ const TransactionInfo = (props: Props) => { return ( - {props.tx.msgs && ( - - Amount: - {printableCoins(props.tx.msgs[0].value.amount, state.chain)} - - )} - {props.tx.msgs && ( - - To: - - - - - )} - {props.tx.fee && ( - <> + <> + {(props.tx.msgs || []).map((msg) => + msg.typeUrl === "/cosmos.bank.v1beta1.MsgSend" ? ( + <> + + Amount: + {printableCoins(msg.value.amount, state.chain)} + + + To: + + + + + > + ) : msg.typeUrl === "/cosmos.staking.v1beta1.MsgDelegate" || + msg.typeUrl === "/cosmos.staking.v1beta1.MsgUndelegate" ? ( + <> + + Amount: + {printableCoin(props.tx.msgs[0].value.amount, state.chain)} + + + Validator Address: + + + + + > + ) : msg.typeUrl === "/cosmos.staking.v1beta1.MsgBeginRedelegate" ? ( + <> + + Amount: + {printableCoin(props.tx.msgs[0].value.amount, state.chain)} + + + Source Validator Address: + + + + + + Destination Validator Address: + + + + + > + ) : msg.typeUrl === "/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward" ? ( + <> + + Amount: + {printableCoin(props.tx.msgs[0].value.amount, state.chain)} + + + Validator Address: + + + + + > + ) : null, + )} + {props.tx.fee && ( + <> + + Gas: + {props.tx.fee.gas} + + + Fee: + {printableCoins(props.tx.fee.amount as Coin[], state.chain)} + + > + )} + {props.tx.memo && ( - Gas: - {props.tx.fee.gas} + Memo: + {props.tx.memo} - - Fee: - {printableCoins(props.tx.fee.amount as Coin[], state.chain)} - - > - )} - {props.tx.memo && ( - - Memo: - {props.tx.memo} - - )} + )} + > + + ); +}; + +export default withRouter(ReDelegationForm); diff --git a/components/forms/RewardsForm.tsx b/components/forms/RewardsForm.tsx new file mode 100644 index 0000000..c1a2b30 --- /dev/null +++ b/components/forms/RewardsForm.tsx @@ -0,0 +1,155 @@ +import axios from "axios"; +import { Account, calculateFee } from "@cosmjs/stargate"; +import { Decimal } from "@cosmjs/math"; +import { assert } from "@cosmjs/utils"; +import React, { useState } from "react"; +import { withRouter, NextRouter } from "next/router"; +import { useAppContext } from "../../context/AppContext"; +import Button from "../inputs/Button"; +import Input from "../inputs/Input"; +import StackableContainer from "../layout/StackableContainer"; +import { checkValidatorAddress } from "../../lib/displayHelpers"; + +interface Props { + address: string | null; + accountOnChain: Account | null; + router: NextRouter; + closeForm: () => void; +} + +const RewardsForm = (props: Props) => { + const { state } = useAppContext(); + const [validatorAddress, setValidatorAddress] = useState(""); + const [amount, setAmount] = useState("0"); + const [memo, setMemo] = useState(""); + const [gas, setGas] = useState(200000); + const [gasPrice, _setGasPrice] = useState(state.chain.gasPrice); + const [_processing, setProcessing] = useState(false); + const [addressError, setAddressError] = useState(""); + + const createTransaction = (txValidatorAddress: string, txAmount: string, txGas: number) => { + const amountInAtomics = Decimal.fromUserInput( + txAmount, + Number(state.chain.displayDenomExponent), + ).atomics; + const msgDelegatorReward = { + delegatorAddress: props.address, + validatorAddress: txValidatorAddress, + amount: { + amount: amountInAtomics, + denom: state.chain.denom, + }, + }; + const msg = { + typeUrl: "/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward", + value: msgDelegatorReward, + }; + assert(gasPrice, "gasPrice missing"); + const fee = calculateFee(Number(txGas), gasPrice); + const { accountOnChain } = props; + assert(accountOnChain, "accountOnChain missing"); + return { + accountNumber: accountOnChain.accountNumber, + sequence: accountOnChain.sequence, + chainId: state.chain.chainId, + msgs: [msg], + fee: fee, + memo: memo, + }; + }; + + const handleCreate = async () => { + assert(state.chain.addressPrefix, "addressPrefix missing"); + const validatorAddressError = checkValidatorAddress(validatorAddress, "cosmosvaloper"); + if (validatorAddressError) { + setAddressError( + `Invalid address for network ${state.chain.chainId}: ${validatorAddressError}`, + ); + return; + } + + setProcessing(true); + const tx = createTransaction(validatorAddress, amount, gas); + console.log(tx, "tx data"); + const dataJSON = JSON.stringify(tx); + const res = await axios.post("/api/transaction", { dataJSON }); + console.log(dataJSON, "tx dataJSON", res); + const { transactionID } = res.data; + props.router.push(`${props.address}/transaction/${transactionID}`); + }; + + assert(state.chain.addressPrefix, "addressPrefix missing"); + + return ( + + props.closeForm()}> + ✕ + + Claim Rewards + + ) => setValidatorAddress(e.target.value)} + error={addressError} + placeholder={`E.g. cosmosvaloper1sjllsnramtg3ewxqwwrwjxfgc4n4ef9u2lcnj0`} + /> + + + ) => setAmount(e.target.value)} + /> + + + ) => + setGas(parseInt(e.target.value, 10)) + } + /> + + + + + + ) => setMemo(e.target.value)} + /> + + + + + ); +}; + +export default withRouter(RewardsForm); diff --git a/components/forms/UnDelegationForm.tsx b/components/forms/UnDelegationForm.tsx new file mode 100644 index 0000000..fdaa8a8 --- /dev/null +++ b/components/forms/UnDelegationForm.tsx @@ -0,0 +1,155 @@ +import axios from "axios"; +import { Account, calculateFee } from "@cosmjs/stargate"; +import { Decimal } from "@cosmjs/math"; +import { assert } from "@cosmjs/utils"; +import React, { useState } from "react"; +import { withRouter, NextRouter } from "next/router"; +import { useAppContext } from "../../context/AppContext"; +import Button from "../inputs/Button"; +import Input from "../inputs/Input"; +import StackableContainer from "../layout/StackableContainer"; +import { checkValidatorAddress } from "../../lib/displayHelpers"; + +interface Props { + address: string | null; + accountOnChain: Account | null; + router: NextRouter; + closeForm: () => void; +} + +const UnDelegationForm = (props: Props) => { + const { state } = useAppContext(); + const [validatorAddress, setValidatorAddress] = useState(""); + const [amount, setAmount] = useState("0"); + const [memo, setMemo] = useState(""); + const [gas, setGas] = useState(300000); + const [gasPrice, _setGasPrice] = useState(state.chain.gasPrice); + const [_processing, setProcessing] = useState(false); + const [addressError, setAddressError] = useState(""); + + const createTransaction = (txValidatorAddress: string, txAmount: string, txGas: number) => { + const amountInAtomics = Decimal.fromUserInput( + txAmount, + Number(state.chain.displayDenomExponent), + ).atomics; + const msgUndelegate = { + delegatorAddress: props.address, + validatorAddress: txValidatorAddress, + amount: { + amount: amountInAtomics, + denom: state.chain.denom, + }, + }; + const msg = { + typeUrl: "/cosmos.staking.v1beta1.MsgUndelegate", + value: msgUndelegate, + }; + assert(gasPrice, "gasPrice missing"); + const fee = calculateFee(Number(txGas), gasPrice); + const { accountOnChain } = props; + assert(accountOnChain, "accountOnChain missing"); + return { + accountNumber: accountOnChain.accountNumber, + sequence: accountOnChain.sequence, + chainId: state.chain.chainId, + msgs: [msg], + fee: fee, + memo: memo, + }; + }; + + const handleCreate = async () => { + assert(state.chain.addressPrefix, "addressPrefix missing"); + const validatorAddressError = checkValidatorAddress(validatorAddress, "cosmosvaloper"); + if (validatorAddressError) { + setAddressError( + `Invalid address for network ${state.chain.chainId}: ${validatorAddressError}`, + ); + return; + } + + setProcessing(true); + const tx = createTransaction(validatorAddress, amount, gas); + console.log(tx, "tx data"); + const dataJSON = JSON.stringify(tx); + const res = await axios.post("/api/transaction", { dataJSON }); + console.log(dataJSON, "tx dataJSON", res); + const { transactionID } = res.data; + props.router.push(`${props.address}/transaction/${transactionID}`); + }; + + assert(state.chain.addressPrefix, "addressPrefix missing"); + + return ( + + props.closeForm()}> + ✕ + + Create UnDelegation + + ) => setValidatorAddress(e.target.value)} + error={addressError} + placeholder={`E.g. cosmosvaloper1sjllsnramtg3ewxqwwrwjxfgc4n4ef9u2lcnj0`} + /> + + + ) => setAmount(e.target.value)} + /> + + + ) => + setGas(parseInt(e.target.value, 10)) + } + /> + + + + + + ) => setMemo(e.target.value)} + /> + + + + + ); +}; + +export default withRouter(UnDelegationForm); diff --git a/lib/displayHelpers.ts b/lib/displayHelpers.ts index c54a587..94cfce5 100644 --- a/lib/displayHelpers.ts +++ b/lib/displayHelpers.ts @@ -125,7 +125,31 @@ const checkAddress = (input: string, chainAddressPrefix: string) => { return null; }; +/** + * Returns an error message for invalid addresses. + * + * Returns null of there is no error. + */ +const checkValidatorAddress = (input: string, chainAddressPrefix: string): string | null => { + if (!input) return "Empty"; + let prefix; + try { + ({ prefix } = fromBech32(input)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + return error.toString(); + } + if (prefix !== chainAddressPrefix) { + return `Expected address prefix '${chainAddressPrefix}' but got '${prefix}'`; + } + + if (input.length !== 52) { + return "Invalid address length in validator address. Must be 52 bytes."; + } + + return null; +}; /** * Returns a link to a transaction in an explorer if an explorer is configured * for transactions. Returns null otherwise. @@ -145,4 +169,5 @@ export { examplePubkey, checkAddress, explorerLinkTx, + checkValidatorAddress, }; diff --git a/pages/multi/[address]/index.tsx b/pages/multi/[address]/index.tsx index 16db4c5..a09491a 100644 --- a/pages/multi/[address]/index.tsx +++ b/pages/multi/[address]/index.tsx @@ -14,6 +14,9 @@ import MultisigMembers from "../../../components/dataViews/MultisigMembers"; import Page from "../../../components/layout/Page"; import StackableContainer from "../../../components/layout/StackableContainer"; import TransactionForm from "../../../components/forms/TransactionForm"; +import UnDelegationForm from "../../../components/forms/UnDelegationForm"; +import ReDelegationForm from "../../../components/forms/ReDelegationForm"; +import RewardsForm from "../../../components/forms/RewardsForm"; function participantPubkeysFromMultisig(multisigPubkey: Pubkey) { return multisigPubkey.value.pubkeys; @@ -27,7 +30,10 @@ function participantAddressesFromMultisig(multisigPubkey: Pubkey, addressPrefix: const multipage = () => { const { state } = useAppContext(); - const [showTxForm, setShowTxForm] = useState(false); + const [showSendTxForm, setShowSendTxForm] = useState(false); + const [showUnDelegateTxForm, setShowUnDelegateTxForm] = useState(false); + const [showReDelegateTxForm, setShowReDelegateTxForm] = useState(false); + const [showRewardsTxForm, setShowRewardsTxForm] = useState(false); const [holdings, setHoldings] = useState(null); const [multisigAddress, setMultisigAddress] = useState(""); const [accountOnChain, setAccountOnChain] = useState(null); @@ -97,15 +103,43 @@ const multipage = () => { )} - {showTxForm ? ( + {showSendTxForm && ( { - setShowTxForm(false); + setShowSendTxForm(false); }} /> - ) : ( + )} + {showUnDelegateTxForm && ( + { + setShowUnDelegateTxForm(false); + }} + /> + )} + {showReDelegateTxForm && ( + { + setShowReDelegateTxForm(false); + }} + /> + )} + {showRewardsTxForm && ( + { + setShowRewardsTxForm(false); + }} + /> + )} + {!showSendTxForm && !showUnDelegateTxForm && !showRewardsTxForm && !showReDelegateTxForm && ( @@ -120,7 +154,25 @@ const multipage = () => { { - setShowTxForm(true); + setShowSendTxForm(true); + }} + /> + { + setShowUnDelegateTxForm(true); + }} + /> + { + setShowRewardsTxForm(true); + }} + /> + { + setShowReDelegateTxForm(true); }} /> @@ -133,10 +185,12 @@ const multipage = () => { display: flex; justify-content: space-between; margin-top: 50px; + flex-direction: column; } .col-1 { flex: 1; - padding-right: 50px; + padding-right: 0; + margin-bottom: 50px; } .col-2 { flex: 1; @@ -147,6 +201,7 @@ const multipage = () => { } p { margin-top: 15px; + max-width: 100%; } .multisig-error p { max-width: 550px;