Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support IBC MsgTransfer #128

Merged
merged 15 commits into from
Jun 13, 2023
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@ yarn-error.log*

# IDE folder
.idea

# Visual Studio Code
.vscode
76 changes: 76 additions & 0 deletions components/dataViews/TransactionInfo/TxMsgTransferDetails.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<li>
<h3>MsgTransfer</h3>
</li>
<li>
<label>Source Port:</label>
<div>{msg.value.sourcePort}</div>
</li>
<li>
<label>Source Channel:</label>
<div>{msg.value.sourceChannel}</div>
</li>
<li>
<label>Token:</label>
<div>{msg.value.token.amount + thinSpace + msg.value.token.denom}</div>
</li>
<li>
<label>To:</label>
<div title={msg.value.receiver}>
<HashView hash={msg.value.receiver} />
</div>
</li>
<li>
<label>Timeout:</label>
<div>
{timeoutDate} {timeoutTime}
</div>
</li>
<li>
<label>Memo:</label>
<div>{msg.value.memo}</div>
</li>
<style jsx>{`
li:not(:has(h3)) {
background: rgba(255, 255, 255, 0.03);
padding: 6px 10px;
border-radius: 8px;
display: flex;
align-items: center;
}
li + li:nth-child(2) {
margin-top: 25px;
}
li + li {
margin-top: 10px;
}
li div {
padding: 3px 6px;
}
label {
font-size: 12px;
background: rgba(255, 255, 255, 0.1);
padding: 3px 6px;
border-radius: 5px;
display: block;
}
`}</style>
</>
);
};

export default TxMsgTransferDetails;
11 changes: 5 additions & 6 deletions components/dataViews/TransactionInfo/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
isTxMsgRedelegate,
isTxMsgSend,
isTxMsgSetWithdrawAddress,
isTxMsgTransfer,
isTxMsgUndelegate,
} from "../../../lib/txMsgHelpers";
import { DbTransaction } from "../../../types";
Expand All @@ -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 {
Expand Down Expand Up @@ -58,12 +60,9 @@ const TransactionInfo = ({ tx }: Props) => {
{isTxMsgUndelegate(msg) ? <TxMsgUndelegateDetails msg={msg} /> : null}
{isTxMsgRedelegate(msg) ? <TxMsgRedelegateDetails msg={msg} /> : null}
{isTxMsgClaimRewards(msg) ? <TxMsgClaimRewardsDetails msg={msg} /> : null}
{isTxMsgSetWithdrawAddress(msg) ? (
<TxMsgSetWithdrawAddressDetails msg={msg} />
) : null}
{isTxMsgCreateVestingAccount(msg) ? (
<TxMsgCreateVestingAccountDetails msg={msg} />
) : null}
{isTxMsgSetWithdrawAddress(msg) ? (<TxMsgSetWithdrawAddressDetails msg={msg} />) : null}
{isTxMsgCreateVestingAccount(msg) ? (<TxMsgCreateVestingAccountDetails msg={msg} />) : null}
{isTxMsgTransfer(msg) ? <TxMsgTransferDetails msg={msg} /> : null}
</StackableContainer>
))}
</StackableContainer>
Expand Down
234 changes: 234 additions & 0 deletions components/forms/CreateTxForm/MsgForm/MsgTransferForm.tsx
Original file line number Diff line number Diff line change
@@ -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,
webmaster128 marked this conversation as resolved.
Show resolved Hide resolved
memo,
},
webmaster128 marked this conversation as resolved.
Show resolved Hide resolved
};

setMsgGetter({ isMsgValid, msg });
}, [
amount,
denom,
fromAddress,
memo,
setMsgGetter,
sourceChannel,
sourcePort,
state.chain.chainId,
timeout,
toAddress,
]);

return (
<StackableContainer lessPadding lessMargin>
<button className="remove" onClick={() => deleteMsg()}>
</button>
<h2>MsgTransfer</h2>
<div className="form-item">
<Input
label="Recipient Address"
name="recipient-address"
value={toAddress}
onChange={({ target }) => setToAddress(target.value)}
error={toAddressError}
placeholder={`E.g. ${exampleAddress(0, state.chain.addressPrefix)}`}
/>
</div>
<div className="form-item">
<Input
label="Denom"
name="denom"
value={denom}
onChange={({ target }) => setDenom(target.value)}
error={denomError}
/>
</div>
<div className="form-item">
<Input
type="number"
label="Amount"
name="amount"
value={amount}
onChange={({ target }) => setAmount(target.value)}
error={amountError}
/>
</div>
<div className="form-item">
<Input
label="Source Port"
name="source-port"
value={sourcePort}
onChange={({ target }) => setSourcePort(target.value)}
error={sourcePortError}
/>
</div>
<div className="form-item">
<Input
label="Source Channel"
name="source-channel"
value={sourceChannel}
onChange={({ target }) => setSourceChannel(target.value)}
error={sourceChannelError}
/>
</div>
<div className="form-item">
<Input
type="text"
list="timestamp-options"
label="Timeout"
name="timeout"
placeholder="Enter timestamp in nanoseconds or select from list"
value={timeout}
onChange={({ target }) => setTimeout(target.value)}
onFocus={() => setTimeout("")}
error={timeoutError}
/>
<datalist id="timestamp-options">
{humanTimestampOptions.map(({ label }) => (
<option key={label}>{label}</option>
))}
</datalist>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried this dropdown in the Vercel preview. But it seems like after it was set once you cannot change it anymore.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah it seems it filters the list, maybe I can clear the field on click so all the options appear

</div>
<div className="form-item">
<Input
label="Memo"
name="memo"
value={memo}
onChange={({ target }) => setMemo(target.value)}
/>
</div>
<style jsx>{`
.form-item {
margin-top: 1.5em;
}
button.remove {
background: rgba(255, 255, 255, 0.2);
width: 30px;
height: 30px;
border-radius: 50%;
border: none;
color: white;
position: absolute;
right: 10px;
top: 10px;
}
`}</style>
</StackableContainer>
);
};

export default MsgTransferForm;
3 changes: 3 additions & 0 deletions components/forms/CreateTxForm/MsgForm/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -31,6 +32,8 @@ const MsgForm = ({ msgType, senderAddress, ...restProps }: MsgFormProps) => {
return <MsgSetWithdrawAddressForm delegatorAddress={senderAddress} {...restProps} />;
case "createVestingAccount":
return <MsgCreateVestingAccountForm fromAddress={senderAddress} {...restProps} />;
case "msgTransfer":
return <MsgTransferForm fromAddress={senderAddress} {...restProps} />;
default:
return null;
}
Expand Down
11 changes: 3 additions & 8 deletions components/forms/CreateTxForm/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,9 @@ const CreateTxForm = ({ router, senderAddress, accountOnChain }: CreateTxFormPro
<Button label="Add MsgUndelegate" onClick={() => addMsgType("undelegate")} />
<Button label="Add MsgBeginRedelegate" onClick={() => addMsgType("redelegate")} />
<Button label="Add MsgWithdrawDelegatorReward" onClick={() => addMsgType("claimRewards")} />
<Button
label="Add MsgSetWithdrawAddress"
onClick={() => addMsgType("setWithdrawAddress")}
/>
<Button
label="Add MsgCreateVestingAccount"
onClick={() => addMsgType("createVestingAccount")}
/>
<Button label="Add MsgSetWithdrawAddress" onClick={() => addMsgType("setWithdrawAddress")} />
<Button label="Add MsgCreateVestingAccount" onClick={() => addMsgType("createVestingAccount")} />
<Button label="Add MsgTransfer" onClick={() => addMsgType("msgTransfer")} />
</StackableContainer>
<Button
label="Create Transaction"
Expand Down
Loading