diff --git a/.gitignore b/.gitignore index c7fbfeb..87469a2 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ yarn-error.log* # IDE folder .idea + +# Visual Studio Code +.vscode diff --git a/components/dataViews/TransactionInfo/TxMsgTransferDetails.tsx b/components/dataViews/TransactionInfo/TxMsgTransferDetails.tsx new file mode 100644 index 0000000..e79577e --- /dev/null +++ b/components/dataViews/TransactionInfo/TxMsgTransferDetails.tsx @@ -0,0 +1,76 @@ +import { thinSpace } from "../../../lib/displayHelpers"; +import { TxMsgTransfer } from "../../../types/txMsg"; +import HashView from "../HashView"; + +interface TxMsgTransferDetailsProps { + readonly msg: TxMsgTransfer; +} + +const TxMsgTransferDetails = ({ msg }: TxMsgTransferDetailsProps) => { + const timeoutDateObj = new Date(msg.value.timeoutTimestamp.divide(1_000_000).toNumber()); + const timeoutDate = timeoutDateObj.toLocaleDateString(); + const timeoutTime = timeoutDateObj.toLocaleTimeString(); + + return ( + <> +
  • +

    MsgTransfer

    +
  • +
  • + +
    {msg.value.sourcePort}
    +
  • +
  • + +
    {msg.value.sourceChannel}
    +
  • +
  • + +
    {msg.value.token.amount + thinSpace + msg.value.token.denom}
    +
  • +
  • + +
    + +
    +
  • +
  • + +
    + {timeoutDate} {timeoutTime} +
    +
  • +
  • + +
    {msg.value.memo}
    +
  • + + + ); +}; + +export default TxMsgTransferDetails; diff --git a/components/dataViews/TransactionInfo/index.tsx b/components/dataViews/TransactionInfo/index.tsx index 1da85d5..616884f 100644 --- a/components/dataViews/TransactionInfo/index.tsx +++ b/components/dataViews/TransactionInfo/index.tsx @@ -7,6 +7,7 @@ import { isTxMsgRedelegate, isTxMsgSend, isTxMsgSetWithdrawAddress, + isTxMsgTransfer, isTxMsgUndelegate, } from "../../../lib/txMsgHelpers"; import { DbTransaction } from "../../../types"; @@ -17,6 +18,7 @@ import TxMsgDelegateDetails from "./TxMsgDelegateDetails"; import TxMsgRedelegateDetails from "./TxMsgRedelegateDetails"; import TxMsgSendDetails from "./TxMsgSendDetails"; import TxMsgSetWithdrawAddressDetails from "./TxMsgSetWithdrawAddressDetails"; +import TxMsgTransferDetails from "./TxMsgTransferDetails"; import TxMsgUndelegateDetails from "./TxMsgUndelegateDetails"; interface Props { @@ -58,12 +60,9 @@ const TransactionInfo = ({ tx }: Props) => { {isTxMsgUndelegate(msg) ? : null} {isTxMsgRedelegate(msg) ? : null} {isTxMsgClaimRewards(msg) ? : null} - {isTxMsgSetWithdrawAddress(msg) ? ( - - ) : null} - {isTxMsgCreateVestingAccount(msg) ? ( - - ) : null} + {isTxMsgSetWithdrawAddress(msg) ? () : null} + {isTxMsgCreateVestingAccount(msg) ? () : null} + {isTxMsgTransfer(msg) ? : null} ))} diff --git a/components/forms/CreateTxForm/MsgForm/MsgTransferForm.tsx b/components/forms/CreateTxForm/MsgForm/MsgTransferForm.tsx new file mode 100644 index 0000000..6ee2a0d --- /dev/null +++ b/components/forms/CreateTxForm/MsgForm/MsgTransferForm.tsx @@ -0,0 +1,234 @@ +import { assert } from "@cosmjs/utils"; +import Long from "long"; +import { useEffect, useState } from "react"; +import { MsgGetter } from ".."; +import { useAppContext } from "../../../../context/AppContext"; +import { checkAddress, exampleAddress } from "../../../../lib/displayHelpers"; +import { isTxMsgTransfer } from "../../../../lib/txMsgHelpers"; +import { TxMsg, TxMsgTransfer } from "../../../../types/txMsg"; +import Input from "../../../inputs/Input"; +import StackableContainer from "../../../layout/StackableContainer"; + +const humanTimestampOptions = [ + { label: "12 hours from now", value: 12 * 60 * 60 * 1000 }, + { label: "1 day from now", value: 24 * 60 * 60 * 1000 }, + { label: "2 days from now", value: 2 * 24 * 60 * 60 * 1000 }, + { label: "3 days from now", value: 3 * 24 * 60 * 60 * 1000 }, + { label: "7 days from now", value: 7 * 24 * 60 * 60 * 1000 }, + { label: "10 days from now", value: 10 * 24 * 60 * 60 * 1000 }, + { label: "2 weeks from now", value: 2 * 7 * 24 * 60 * 60 * 1000 }, + { label: "3 weeks from now", value: 3 * 7 * 24 * 60 * 60 * 1000 }, + { label: "1 month from now", value: 4 * 7 * 24 * 60 * 60 * 1000 }, +]; + +interface MsgTransferFormProps { + readonly fromAddress: string; + readonly setMsgGetter: (msgGetter: MsgGetter) => void; + readonly deleteMsg: () => void; +} + +const MsgTransferForm = ({ fromAddress, setMsgGetter, deleteMsg }: MsgTransferFormProps) => { + const { state } = useAppContext(); + assert(state.chain.addressPrefix, "addressPrefix missing"); + + const [sourcePort, setSourcePort] = useState("transfer"); + const [sourceChannel, setSourceChannel] = useState(""); + const [denom, setDenom] = useState(""); + const [amount, setAmount] = useState("0"); + const [toAddress, setToAddress] = useState(""); + const [timeout, setTimeout] = useState(""); + const [memo, setMemo] = useState(""); + + const [sourcePortError, setSourcePortError] = useState(""); + const [sourceChannelError, setSourceChannelError] = useState(""); + const [denomError, setDenomError] = useState(""); + const [amountError, setAmountError] = useState(""); + const [toAddressError, setToAddressError] = useState(""); + const [timeoutError, setTimeoutError] = useState(""); + + useEffect(() => { + setSourcePortError(""); + setSourceChannelError(""); + setDenomError(""); + setAmountError(""); + setToAddressError(""); + setTimeoutError(""); + + const isMsgValid = (msg: TxMsg): msg is TxMsgTransfer => { + if (!sourcePort) { + setSourcePortError("Source port is required"); + return false; + } + + if (!sourceChannel) { + setSourceChannelError("Source channel is required"); + return false; + } + + if (!denom) { + setDenomError("Denom is required"); + return false; + } + + if (!amount || Number(amount) <= 0) { + setAmountError("Amount must be greater than 0"); + return false; + } + + const addressErrorMsg = checkAddress(toAddress, ""); // Allow address from any chain + if (addressErrorMsg) { + setToAddressError(`Invalid address for network ${state.chain.chainId}: ${addressErrorMsg}`); + return false; + } + + const foundTimeout = humanTimestampOptions.find(({ label }) => label === timeout); + const isTimeoutNumber = !isNaN(Number(timeout)); + const isTimeoutInFuture = Number(timeout) > Date.now(); + + if (!foundTimeout || !isTimeoutNumber || !isTimeoutInFuture) { + setTimeoutError("Timeout must be a valid timestamp in the future"); + } + + return isTxMsgTransfer(msg); + }; + + const timeoutTimestamp = (() => { + const foundTimeout = humanTimestampOptions.find(({ label }) => label === timeout)?.value; + if (foundTimeout) { + return Long.fromNumber(Date.now() + foundTimeout).multiply(1_000_000); // In nanoseconds + } + + try { + return Long.fromString(timeout); + } catch { + return Long.fromNumber(0); + } + })(); + + const msg: TxMsgTransfer = { + typeUrl: "/ibc.applications.transfer.v1.MsgTransfer", + value: { + sender: fromAddress, + receiver: toAddress, + token: { denom, amount }, + sourcePort, + sourceChannel, + timeoutTimestamp, + memo, + }, + }; + + setMsgGetter({ isMsgValid, msg }); + }, [ + amount, + denom, + fromAddress, + memo, + setMsgGetter, + sourceChannel, + sourcePort, + state.chain.chainId, + timeout, + toAddress, + ]); + + return ( + + +

    MsgTransfer

    +
    + setToAddress(target.value)} + error={toAddressError} + placeholder={`E.g. ${exampleAddress(0, state.chain.addressPrefix)}`} + /> +
    +
    + setDenom(target.value)} + error={denomError} + /> +
    +
    + setAmount(target.value)} + error={amountError} + /> +
    +
    + setSourcePort(target.value)} + error={sourcePortError} + /> +
    +
    + setSourceChannel(target.value)} + error={sourceChannelError} + /> +
    +
    + setTimeout(target.value)} + onFocus={() => setTimeout("")} + error={timeoutError} + /> + + {humanTimestampOptions.map(({ label }) => ( + + ))} + +
    +
    + setMemo(target.value)} + /> +
    + +
    + ); +}; + +export default MsgTransferForm; diff --git a/components/forms/CreateTxForm/MsgForm/index.tsx b/components/forms/CreateTxForm/MsgForm/index.tsx index 3760210..1489fa9 100644 --- a/components/forms/CreateTxForm/MsgForm/index.tsx +++ b/components/forms/CreateTxForm/MsgForm/index.tsx @@ -6,6 +6,7 @@ import MsgDelegateForm from "./MsgDelegateForm"; import MsgRedelegateForm from "./MsgRedelegateForm"; import MsgSendForm from "./MsgSendForm"; import MsgSetWithdrawAddressForm from "./MsgSetWithdrawAddressForm"; +import MsgTransferForm from "./MsgTransferForm"; import MsgUndelegateForm from "./MsgUndelegateForm"; interface MsgFormProps { @@ -31,6 +32,8 @@ const MsgForm = ({ msgType, senderAddress, ...restProps }: MsgFormProps) => { return ; case "createVestingAccount": return ; + case "msgTransfer": + return ; default: return null; } diff --git a/components/forms/CreateTxForm/index.tsx b/components/forms/CreateTxForm/index.tsx index 9d53e80..f29b0a1 100644 --- a/components/forms/CreateTxForm/index.tsx +++ b/components/forms/CreateTxForm/index.tsx @@ -144,14 +144,9 @@ const CreateTxForm = ({ router, senderAddress, accountOnChain }: CreateTxFormPro