Merge pull request #154 from cosmos/feat/support-send-denoms
Support send custom denoms
This commit is contained in:
@@ -62,6 +62,7 @@ const ChainSelect = () => {
|
||||
const [tempDisplayDenomExponent, setDisplayDenomExponent] = useState(
|
||||
state.chain.displayDenomExponent,
|
||||
);
|
||||
const [tempAssets, setAssets] = useState(state.chain.assets);
|
||||
const [tempGasPrice, setGasPrice] = useState(state.chain.gasPrice);
|
||||
const [tempChainName, setChainName] = useState(state.chain.chainDisplayName);
|
||||
const [tempRegistryName, setRegistryName] = useState(state.chain.registryName);
|
||||
@@ -110,6 +111,7 @@ const ChainSelect = () => {
|
||||
setDenom(state.chain.denom);
|
||||
setDisplayDenom(state.chain.displayDenom);
|
||||
setDisplayDenomExponent(state.chain.displayDenomExponent);
|
||||
setAssets(state.chain.assets);
|
||||
setGasPrice(state.chain.gasPrice);
|
||||
setChainName(state.chain.chainDisplayName);
|
||||
setExplorerLink(state.chain.explorerLink);
|
||||
@@ -133,25 +135,27 @@ const ChainSelect = () => {
|
||||
|
||||
try {
|
||||
const chainData = await getChainFromRegistry(chainOption.path);
|
||||
const assets = await getAssetsFromRegistry(chainOption.path);
|
||||
const firstAsset = assets[0];
|
||||
const registryAssets = await getAssetsFromRegistry(chainOption.path);
|
||||
assert(registryAssets.length >= 1, "No assets found in registry");
|
||||
const firstAsset = registryAssets[0];
|
||||
|
||||
const nodeAddress = await getNodeFromArray(chainData.apis.rpc);
|
||||
const explorerLink = getExplorerFromArray(chainData.explorers);
|
||||
const denom = firstAsset.base || "";
|
||||
const displayDenom = firstAsset.symbol || "";
|
||||
|
||||
const firstAssetDenom = firstAsset.base;
|
||||
const displayDenom = firstAsset.symbol;
|
||||
const displayUnit = firstAsset.denom_units.find((u) => u.denom == firstAsset.display);
|
||||
const displayDenomExponent = displayUnit?.exponent ?? 6;
|
||||
|
||||
const feeToken = chainData.fees.fee_tokens.find((token) => token.denom == denom) ?? { denom };
|
||||
const feeToken = chainData.fees.fee_tokens.find(
|
||||
(token) => token.denom == firstAssetDenom,
|
||||
) ?? { denom: firstAssetDenom };
|
||||
const gasPrice =
|
||||
feeToken.average_gas_price ??
|
||||
feeToken.low_gas_price ??
|
||||
feeToken.high_gas_price ??
|
||||
feeToken.fixed_min_gas_price ??
|
||||
0.03;
|
||||
const formattedGasPrice = firstAsset ? `${gasPrice}${denom}` : "";
|
||||
const formattedGasPrice = firstAsset ? `${gasPrice}${firstAssetDenom}` : "";
|
||||
|
||||
// change app state
|
||||
dispatch({
|
||||
@@ -163,10 +167,11 @@ const ChainSelect = () => {
|
||||
chainDisplayName: chainData.pretty_name,
|
||||
nodeAddress,
|
||||
explorerLink,
|
||||
denom,
|
||||
denom: firstAssetDenom,
|
||||
displayDenom,
|
||||
displayDenomExponent,
|
||||
gasPrice: formattedGasPrice,
|
||||
assets: registryAssets,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -253,6 +258,7 @@ const ChainSelect = () => {
|
||||
denom: tempDenom,
|
||||
displayDenom: tempDisplayDenom,
|
||||
displayDenomExponent: tempDisplayDenomExponent,
|
||||
assets: tempAssets,
|
||||
gasPrice: tempGasPrice,
|
||||
chainId: tempChainId,
|
||||
chainDisplayName: tempChainName,
|
||||
@@ -367,14 +373,22 @@ const ChainSelect = () => {
|
||||
}
|
||||
label="Denom Exponent"
|
||||
/>
|
||||
<Input
|
||||
width="48%"
|
||||
value={JSON.stringify(tempAssets)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setAssets(JSON.parse(e.target.value))
|
||||
}
|
||||
label="Assets"
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<Input
|
||||
width="48%"
|
||||
value={tempGasPrice}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setGasPrice(e.target.value)}
|
||||
label="Gas Price"
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<Input
|
||||
width="48%"
|
||||
value={tempExplorerLink}
|
||||
|
||||
@@ -46,7 +46,7 @@ export interface RegistryChainResponse {
|
||||
export interface RegistryAssetDenomUnit {
|
||||
denom: string;
|
||||
exponent: number;
|
||||
aliases: string[];
|
||||
aliases?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,10 +5,22 @@ import { useEffect, useState } from "react";
|
||||
import { MsgGetter } from "..";
|
||||
import { useAppContext } from "../../../../context/AppContext";
|
||||
import { checkAddress, exampleAddress } from "../../../../lib/displayHelpers";
|
||||
import { ChainInfo } from "../../../../types";
|
||||
import { MsgCodecs, MsgTypeUrls } from "../../../../types/txMsg";
|
||||
import Input from "../../../inputs/Input";
|
||||
import Select from "../../../inputs/Select";
|
||||
import StackableContainer from "../../../layout/StackableContainer";
|
||||
|
||||
const customDenomOption = { label: "Custom (enter denom below)", value: "custom" } as const;
|
||||
|
||||
const getDenomOptions = (assets: ChainInfo["assets"]) => {
|
||||
if (!assets?.length) {
|
||||
return [customDenomOption];
|
||||
}
|
||||
|
||||
return [...assets.map((asset) => ({ label: asset.symbol, value: asset })), customDenomOption];
|
||||
};
|
||||
|
||||
interface MsgSendFormProps {
|
||||
readonly fromAddress: string;
|
||||
readonly setMsgGetter: (msgGetter: MsgGetter) => void;
|
||||
@@ -19,60 +31,90 @@ const MsgSendForm = ({ fromAddress, setMsgGetter, deleteMsg }: MsgSendFormProps)
|
||||
const { state } = useAppContext();
|
||||
assert(state.chain.addressPrefix, "addressPrefix missing");
|
||||
|
||||
const denomOptions = getDenomOptions(state.chain.assets);
|
||||
|
||||
const [toAddress, setToAddress] = useState("");
|
||||
const [selectedDenom, setSelectedDenom] = useState(denomOptions[0]);
|
||||
const [customDenom, setCustomDenom] = useState("");
|
||||
const [amount, setAmount] = useState("0");
|
||||
|
||||
const [toAddressError, setToAddressError] = useState("");
|
||||
const [customDenomError, setCustomDenomError] = useState("");
|
||||
const [amountError, setAmountError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
assert(state.chain.denom, "denom missing");
|
||||
assert(state.chain.denom, "denom missing");
|
||||
|
||||
setToAddressError("");
|
||||
setAmountError("");
|
||||
setToAddressError("");
|
||||
setCustomDenomError("");
|
||||
setAmountError("");
|
||||
|
||||
const isMsgValid = (): boolean => {
|
||||
assert(state.chain.addressPrefix, "addressPrefix missing");
|
||||
const isMsgValid = (): boolean => {
|
||||
assert(state.chain.addressPrefix, "addressPrefix missing");
|
||||
|
||||
const addressErrorMsg = checkAddress(toAddress, state.chain.addressPrefix);
|
||||
if (addressErrorMsg) {
|
||||
setToAddressError(
|
||||
`Invalid address for network ${state.chain.chainId}: ${addressErrorMsg}`,
|
||||
);
|
||||
return false;
|
||||
const addressErrorMsg = checkAddress(toAddress, state.chain.addressPrefix);
|
||||
if (addressErrorMsg) {
|
||||
setToAddressError(`Invalid address for network ${state.chain.chainId}: ${addressErrorMsg}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (selectedDenom.value === customDenomOption.value && !customDenom) {
|
||||
setCustomDenomError("Custom denom must be set because of selection above");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!amount || Number(amount) <= 0) {
|
||||
setAmountError("Amount must be greater than 0");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (selectedDenom.value === customDenomOption.value && !Number.isInteger(Number(amount))) {
|
||||
setAmountError("Amount cannot be decimal for custom denom");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const denom =
|
||||
selectedDenom.value === customDenomOption.value ? customDenom : selectedDenom.value.symbol;
|
||||
|
||||
const amountInAtomics = (() => {
|
||||
try {
|
||||
if (selectedDenom.value === customDenomOption.value) {
|
||||
return Decimal.fromUserInput(amount, 0).atomics;
|
||||
}
|
||||
|
||||
if (!amount || Number(amount) <= 0) {
|
||||
setAmountError("Amount must be greater than 0");
|
||||
return false;
|
||||
}
|
||||
const foundAsset = state.chain.assets?.find((asset) => asset.symbol === denom);
|
||||
const exponent =
|
||||
foundAsset?.denom_units.find((unit) => unit.denom === foundAsset.symbol.toLowerCase())
|
||||
?.exponent ?? 0;
|
||||
|
||||
return true;
|
||||
};
|
||||
return Decimal.fromUserInput(amount, exponent).atomics;
|
||||
} catch {
|
||||
return "0";
|
||||
}
|
||||
})();
|
||||
|
||||
const amountInAtomics = amount
|
||||
? Decimal.fromUserInput(amount, Number(state.chain.displayDenomExponent)).atomics
|
||||
: "0";
|
||||
const msgValue = MsgCodecs[MsgTypeUrls.Send].fromPartial({
|
||||
fromAddress,
|
||||
toAddress,
|
||||
amount: [{ denom, amount: amountInAtomics }],
|
||||
});
|
||||
|
||||
const msgValue = MsgCodecs[MsgTypeUrls.Send].fromPartial({
|
||||
fromAddress,
|
||||
toAddress,
|
||||
amount: [{ amount: amountInAtomics, denom: state.chain.denom }],
|
||||
});
|
||||
const msg: MsgSendEncodeObject = { typeUrl: MsgTypeUrls.Send, value: msgValue };
|
||||
|
||||
const msg: MsgSendEncodeObject = { typeUrl: MsgTypeUrls.Send, value: msgValue };
|
||||
|
||||
setMsgGetter({ isMsgValid, msg });
|
||||
} catch {}
|
||||
setMsgGetter({ isMsgValid, msg });
|
||||
}, [
|
||||
amount,
|
||||
customDenom,
|
||||
fromAddress,
|
||||
selectedDenom.value,
|
||||
setMsgGetter,
|
||||
state.chain.addressPrefix,
|
||||
state.chain.assets,
|
||||
state.chain.chainId,
|
||||
state.chain.denom,
|
||||
state.chain.displayDenomExponent,
|
||||
toAddress,
|
||||
]);
|
||||
|
||||
@@ -92,10 +134,40 @@ const MsgSendForm = ({ fromAddress, setMsgGetter, deleteMsg }: MsgSendFormProps)
|
||||
placeholder={`E.g. ${exampleAddress(0, state.chain.addressPrefix)}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-item form-select">
|
||||
<label>Choose a denom:</label>
|
||||
<Select
|
||||
label="Select denom"
|
||||
name="denom-select"
|
||||
options={denomOptions}
|
||||
value={selectedDenom}
|
||||
onChange={(option: (typeof denomOptions)[number]) => {
|
||||
setSelectedDenom(option);
|
||||
if (option.value !== customDenomOption.value) {
|
||||
setCustomDenom("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-item">
|
||||
<Input
|
||||
label="Custom denom"
|
||||
name="custom-denom"
|
||||
value={customDenom}
|
||||
onChange={({ target }) => setCustomDenom(target.value)}
|
||||
placeholder={
|
||||
selectedDenom.value === customDenomOption.value
|
||||
? "Enter custom denom"
|
||||
: "Select Custom denom above"
|
||||
}
|
||||
disabled={selectedDenom.value !== customDenomOption.value}
|
||||
error={customDenomError}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-item">
|
||||
<Input
|
||||
type="number"
|
||||
label={`Amount (${state.chain.displayDenom})`}
|
||||
label="Amount"
|
||||
name="amount"
|
||||
value={amount}
|
||||
onChange={({ target }) => setAmount(target.value)}
|
||||
@@ -106,6 +178,15 @@ const MsgSendForm = ({ fromAddress, setMsgGetter, deleteMsg }: MsgSendFormProps)
|
||||
.form-item {
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
.form-item label {
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
}
|
||||
.form-select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8em;
|
||||
}
|
||||
button.remove {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
width: 30px;
|
||||
|
||||
+10
-8
@@ -1,7 +1,6 @@
|
||||
import React, { useEffect, createContext, useContext, useReducer } from "react";
|
||||
|
||||
import { AppReducer, ChangeChainAction, initialState } from "./AppReducer";
|
||||
import { createContext, useContext, useEffect, useReducer } from "react";
|
||||
import { ChainInfo } from "../types";
|
||||
import { AppReducer, ChangeChainAction, initialState } from "./AppReducer";
|
||||
|
||||
export interface AppContextType {
|
||||
chain: ChainInfo;
|
||||
@@ -23,6 +22,7 @@ function getChainInfoFromUrl(): ChainInfo {
|
||||
decodeURIComponent(params.get("displayDenomExponent") || ""),
|
||||
10,
|
||||
),
|
||||
assets: JSON.parse(decodeURIComponent(params.get("assets") || "{}")),
|
||||
gasPrice: decodeURIComponent(params.get("gasPrice") || ""),
|
||||
chainId: decodeURIComponent(params.get("chainId") || ""),
|
||||
chainDisplayName: decodeURIComponent(params.get("chainDisplayName") || ""),
|
||||
@@ -37,11 +37,13 @@ function getChainInfoFromUrl(): ChainInfo {
|
||||
function setChainInfoParams(chainInfo: ChainInfo) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const keys = Object.keys(chainInfo) as Array<keyof ChainInfo>;
|
||||
|
||||
keys.forEach((value: keyof ChainInfo) => {
|
||||
params.set(value, encodeURIComponent(chainInfo[value] || ""));
|
||||
});
|
||||
for (const [key, value] of Object.entries(chainInfo)) {
|
||||
if (Array.isArray(value)) {
|
||||
params.set(key, encodeURIComponent(JSON.stringify(value)));
|
||||
} else {
|
||||
params.set(key, encodeURIComponent(value ?? ""));
|
||||
}
|
||||
}
|
||||
|
||||
window.history.replaceState({}, "", `${location.pathname}?${params}`);
|
||||
}
|
||||
|
||||
@@ -12,13 +12,37 @@ const testChainInfo: ChainInfo = {
|
||||
denom: "ujunox",
|
||||
displayDenom: "JUNOX",
|
||||
displayDenomExponent: 6,
|
||||
assets: [
|
||||
{
|
||||
description: "The native token of JUNO Chain",
|
||||
denom_units: [
|
||||
{
|
||||
denom: "ujunox",
|
||||
exponent: 0,
|
||||
},
|
||||
{
|
||||
denom: "junox",
|
||||
exponent: 6,
|
||||
},
|
||||
],
|
||||
base: "ujunox",
|
||||
name: "Juno Testnet",
|
||||
display: "junox",
|
||||
symbol: "JUNOX",
|
||||
logo_URIs: {
|
||||
png: "https://raw.githubusercontent.com/cosmos/chain-registry/master/testnets/junotestnet/images/juno.png",
|
||||
svg: "https://raw.githubusercontent.com/cosmos/chain-registry/master/testnets/junotestnet/images/juno.svg",
|
||||
},
|
||||
coingecko_id: "juno-network",
|
||||
},
|
||||
],
|
||||
gasPrice: "0.04ujunox",
|
||||
};
|
||||
|
||||
const emptyCoin: Coin = { amount: "", denom: "" };
|
||||
const coinInChain: Coin = { amount: "1000", denom: "ujunox" };
|
||||
const coinNotInChainLeading: Coin = { amount: "20000", denom: "utest" };
|
||||
const coinNotInChainNotLeading: Coin = { amount: "300000", denom: "test" };
|
||||
const coinNotInChainNotLeading: Coin = { amount: "300000", denom: "TEST" };
|
||||
const ibcCoin: Coin = {
|
||||
amount: "4000000",
|
||||
denom: "ibc/c4cff46fd6de35ca4cf4ce031e643c8fdc9ba4b99ae598e9b0ed98fe3a2319f9",
|
||||
@@ -34,17 +58,17 @@ describe("printableCoin", () => {
|
||||
});
|
||||
|
||||
it("works with coin not in ChainInfo with leading 'u'", () => {
|
||||
expect(printableCoin(coinNotInChainLeading, testChainInfo)).toEqual(`0.02${thinSpace}TEST`);
|
||||
expect(printableCoin(coinNotInChainLeading, testChainInfo)).toEqual(`20000${thinSpace}UTEST`);
|
||||
});
|
||||
|
||||
it("works with coin not in ChainInfo without leading 'u'", () => {
|
||||
expect(printableCoin(coinNotInChainNotLeading, testChainInfo)).toEqual(
|
||||
`300000${thinSpace}test`,
|
||||
`300000${thinSpace}TEST`,
|
||||
);
|
||||
});
|
||||
|
||||
it("works with IBC coin", () => {
|
||||
expect(printableCoin(ibcCoin, testChainInfo)).toEqual(`4000000${thinSpace}ibc/c4cff…319f9`);
|
||||
expect(printableCoin(ibcCoin, testChainInfo)).toEqual(`4000000${thinSpace}ibc/C4CFF…319F9`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,7 +91,7 @@ describe("printableCoins", () => {
|
||||
];
|
||||
|
||||
expect(printableCoins(coins, testChainInfo)).toEqual(
|
||||
`0.001${thinSpace}JUNOX, 0.02${thinSpace}TEST, 300000${thinSpace}test, 4000000 ibc/c4cff…319f9`,
|
||||
`0.001${thinSpace}JUNOX, 20000${thinSpace}UTEST, 300000${thinSpace}TEST, 4000000 ibc/C4CFF…319F9`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+15
-17
@@ -37,32 +37,30 @@ const thinSpace = "\u202F";
|
||||
const printableCoin = (coin: Coin, chainInfo: ChainInfo) => {
|
||||
if (!coin.amount || !coin.denom) return "";
|
||||
|
||||
// The display denom from configuration
|
||||
if (coin.denom === chainInfo.denom) {
|
||||
const exponent = Number(chainInfo.displayDenomExponent);
|
||||
const value = Decimal.fromAtomics(coin.amount ?? "0", exponent).toString();
|
||||
const ticker = chainInfo.displayDenom;
|
||||
return value + thinSpace + ticker;
|
||||
}
|
||||
|
||||
// Auto-convert leading "u"s
|
||||
if (coin.denom.startsWith("u")) {
|
||||
const value = Decimal.fromAtomics(coin.amount ?? "0", 6).toString();
|
||||
const ticker = coin.denom.slice(1).toUpperCase();
|
||||
return value + thinSpace + ticker;
|
||||
}
|
||||
|
||||
// Ellide IBC tokens
|
||||
if (coin.denom.startsWith("ibc/")) {
|
||||
const value = coin.amount;
|
||||
const hash = coin.denom.slice(4);
|
||||
const ellidedHash = ellideMiddle(hash, 11);
|
||||
const ticker = `ibc/${ellidedHash}`;
|
||||
const ticker = `ibc/${ellidedHash.toUpperCase()}`;
|
||||
return value + thinSpace + ticker;
|
||||
}
|
||||
|
||||
const foundAsset = chainInfo.assets?.find(
|
||||
(asset) => coin.denom === asset.symbol || coin.denom === asset.base,
|
||||
);
|
||||
const foundExponent = foundAsset?.denom_units.find(
|
||||
(unit) => unit.denom === foundAsset.symbol.toLowerCase(),
|
||||
)?.exponent;
|
||||
|
||||
if (foundExponent) {
|
||||
const value = Decimal.fromAtomics(coin.amount, foundExponent).toString();
|
||||
const ticker = foundAsset.symbol;
|
||||
return value + thinSpace + ticker;
|
||||
}
|
||||
|
||||
// Fallback to plain coin display
|
||||
return coin.amount + thinSpace + coin.denom;
|
||||
return coin.amount + thinSpace + coin.denom.toUpperCase();
|
||||
};
|
||||
|
||||
const printableCoins = (coins: readonly Coin[], chainInfo: ChainInfo) =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { StdFee } from "@cosmjs/amino";
|
||||
import { EncodeObject } from "@cosmjs/proto-signing";
|
||||
import { RegistryAsset } from "../components/chainSelect/chainregistry";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -51,6 +52,7 @@ export interface ChainInfo {
|
||||
denom?: string;
|
||||
displayDenom?: string;
|
||||
displayDenomExponent?: number;
|
||||
assets?: readonly RegistryAsset[];
|
||||
gasPrice?: string;
|
||||
chainId?: string;
|
||||
chainDisplayName?: string;
|
||||
|
||||
Reference in New Issue
Block a user