Merge pull request #128 from cosmos/feat/support-ibc-msgtransfer
Support IBC MsgTransfer
This commit is contained in:
@@ -25,3 +25,6 @@ yarn-error.log*
|
||||
|
||||
# IDE folder
|
||||
.idea
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode
|
||||
|
||||
@@ -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;
|
||||
@@ -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) ? <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>
|
||||
|
||||
@@ -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 (
|
||||
<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>
|
||||
</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;
|
||||
@@ -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 <MsgSetWithdrawAddressForm delegatorAddress={senderAddress} {...restProps} />;
|
||||
case "createVestingAccount":
|
||||
return <MsgCreateVestingAccountForm fromAddress={senderAddress} {...restProps} />;
|
||||
case "msgTransfer":
|
||||
return <MsgTransferForm fromAddress={senderAddress} {...restProps} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
interface Props {
|
||||
import { DetailedHTMLProps, InputHTMLAttributes } from "react";
|
||||
|
||||
interface InputProps
|
||||
extends DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> {
|
||||
label?: string;
|
||||
type?: string;
|
||||
name?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
value: number | string | undefined;
|
||||
min?: string | number | undefined;
|
||||
checked?: boolean;
|
||||
onBlur?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
placeholder?: string;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
const Input = (props: Props) => (
|
||||
const Input = (props: InputProps) => (
|
||||
<div className="text-input">
|
||||
<label>{props.label || ""}</label>
|
||||
<input
|
||||
type={props.type || "text"}
|
||||
list={props.list}
|
||||
name={props.name || "text-input"}
|
||||
onChange={props.onChange}
|
||||
onFocus={props.onFocus}
|
||||
value={props.value}
|
||||
checked={props.checked}
|
||||
min={props.type === "datetime-local" ? props.min : undefined}
|
||||
|
||||
+25
-3
@@ -10,6 +10,7 @@ import {
|
||||
TxMsgRedelegate,
|
||||
TxMsgSend,
|
||||
TxMsgSetWithdrawAddress,
|
||||
TxMsgTransfer,
|
||||
TxMsgUndelegate,
|
||||
} from "../types/txMsg";
|
||||
|
||||
@@ -85,6 +86,18 @@ const isTxMsgCreateVestingAccount = (msg: TxMsg | EncodeObject): msg is TxMsgCre
|
||||
!!msg.value.endTime &&
|
||||
typeof msg.value.delayed === "boolean";
|
||||
|
||||
const isTxMsgTransfer = (msg: TxMsg | EncodeObject): msg is TxMsgTransfer =>
|
||||
msg.typeUrl === "/ibc.applications.transfer.v1.MsgTransfer" &&
|
||||
"value" in msg &&
|
||||
"sourcePort" in msg.value &&
|
||||
"sourceChannel" in msg.value &&
|
||||
"sender" in msg.value &&
|
||||
"receiver" in msg.value &&
|
||||
!!msg.value.sourcePort &&
|
||||
!!msg.value.sourceChannel &&
|
||||
!!msg.value.sender &&
|
||||
!!msg.value.receiver;
|
||||
|
||||
const gasOfMsg = (msgType: MsgType): number => {
|
||||
switch (msgType) {
|
||||
case "send":
|
||||
@@ -101,6 +114,8 @@ const gasOfMsg = (msgType: MsgType): number => {
|
||||
return 100_000;
|
||||
case "createVestingAccount":
|
||||
return 100_000;
|
||||
case "msgTransfer":
|
||||
return 100_000;
|
||||
default:
|
||||
throw new Error("Unknown msg type");
|
||||
}
|
||||
@@ -112,16 +127,22 @@ const gasOfTx = (msgTypes: readonly MsgType[]): number => {
|
||||
return totalTxGas;
|
||||
};
|
||||
|
||||
const importMsgCreateVestingAccount = (msg: EncodeObject): EncodeObject => {
|
||||
const importMsgType = (msg: EncodeObject): EncodeObject => {
|
||||
if (isTxMsgCreateVestingAccount(msg)) {
|
||||
return { ...msg, value: { ...msg.value, endTime: Long.fromValue(msg.value.endTime) } };
|
||||
}
|
||||
|
||||
if (isTxMsgTransfer(msg)) {
|
||||
return {
|
||||
...msg,
|
||||
value: { ...msg.value, timeoutTimestamp: Long.fromValue(msg.value.timeoutTimestamp) },
|
||||
};
|
||||
}
|
||||
|
||||
return msg;
|
||||
};
|
||||
|
||||
const importMsgTypes = (msgs: readonly EncodeObject[]): EncodeObject[] =>
|
||||
msgs.map(importMsgCreateVestingAccount);
|
||||
const importMsgTypes = (msgs: readonly EncodeObject[]): EncodeObject[] => msgs.map(importMsgType);
|
||||
|
||||
const dbTxFromJson = (txJson: string): DbTransaction | null => {
|
||||
try {
|
||||
@@ -148,6 +169,7 @@ export {
|
||||
isTxMsgClaimRewards,
|
||||
isTxMsgSetWithdrawAddress,
|
||||
isTxMsgCreateVestingAccount,
|
||||
isTxMsgTransfer,
|
||||
gasOfMsg,
|
||||
gasOfTx,
|
||||
dbTxFromJson,
|
||||
|
||||
+13
-3
@@ -1,4 +1,5 @@
|
||||
import { Coin } from "@cosmjs/amino";
|
||||
import { Coin, MsgTransferEncodeObject } from "@cosmjs/stargate";
|
||||
import { MsgTransfer } from "cosmjs-types/ibc/applications/transfer/v1/tx";
|
||||
|
||||
export type MsgType =
|
||||
| "send"
|
||||
@@ -7,7 +8,8 @@ export type MsgType =
|
||||
| "redelegate"
|
||||
| "claimRewards"
|
||||
| "setWithdrawAddress"
|
||||
| "createVestingAccount";
|
||||
| "createVestingAccount"
|
||||
| "msgTransfer";
|
||||
|
||||
export type TxMsg =
|
||||
| TxMsgSend
|
||||
@@ -16,7 +18,8 @@ export type TxMsg =
|
||||
| TxMsgRedelegate
|
||||
| TxMsgClaimRewards
|
||||
| TxMsgSetWithdrawAddress
|
||||
| TxMsgCreateVestingAccount;
|
||||
| TxMsgCreateVestingAccount
|
||||
| TxMsgTransfer;
|
||||
|
||||
export interface TxMsgSend {
|
||||
readonly typeUrl: "/cosmos.bank.v1beta1.MsgSend";
|
||||
@@ -81,3 +84,10 @@ export interface TxMsgCreateVestingAccount {
|
||||
readonly delayed: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
type OmmitedMsgTransfer = Omit<MsgTransfer, "timeoutHeight">;
|
||||
type MsgTransferRequiredToken = OmmitedMsgTransfer & Required<Pick<OmmitedMsgTransfer, "token">>;
|
||||
|
||||
export interface TxMsgTransfer extends MsgTransferEncodeObject {
|
||||
readonly value: Readonly<MsgTransferRequiredToken>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user