Merge pull request #154 from cosmos/feat/support-send-denoms

Support send custom denoms
This commit is contained in:
Abel Fernández
2023-06-27 13:11:41 +02:00
committed by GitHub
7 changed files with 194 additions and 73 deletions
+24 -10
View File
@@ -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}
+1 -1
View File
@@ -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
View File
@@ -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}`);
}
+29 -5
View File
@@ -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, 4000000ibc/c4cff…319f9`,
`0.001${thinSpace}JUNOX, 20000${thinSpace}UTEST, 300000${thinSpace}TEST, 4000000ibc/C4CFF…319F9`,
);
});
});
+15 -17
View File
@@ -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) =>
+2
View File
@@ -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;