diff --git a/components/chainSelect/ChainSelect.tsx b/components/chainSelect/ChainSelect.tsx index 91dce56..93abf97 100644 --- a/components/chainSelect/ChainSelect.tsx +++ b/components/chainSelect/ChainSelect.tsx @@ -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" /> + ) => + setAssets(JSON.parse(e.target.value)) + } + label="Assets" + /> + +
) => setGasPrice(e.target.value)} label="Gas Price" /> -
-
{ + 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)}`} />
+
+ + setCustomDenom(target.value)} + placeholder={ + selectedDenom.value === customDenomOption.value + ? "Enter custom denom" + : "Select Custom denom above" + } + disabled={selectedDenom.value !== customDenomOption.value} + error={customDenomError} + /> +
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; diff --git a/context/AppContext.tsx b/context/AppContext.tsx index f58e2ca..689b5ab 100644 --- a/context/AppContext.tsx +++ b/context/AppContext.tsx @@ -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; - - 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}`); } diff --git a/lib/displayHelpers.spec.ts b/lib/displayHelpers.spec.ts index 385c1b0..9f04922 100644 --- a/lib/displayHelpers.spec.ts +++ b/lib/displayHelpers.spec.ts @@ -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`, ); }); }); diff --git a/lib/displayHelpers.ts b/lib/displayHelpers.ts index 34466b0..100bf61 100644 --- a/lib/displayHelpers.ts +++ b/lib/displayHelpers.ts @@ -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) => diff --git a/types/index.ts b/types/index.ts index 16c5879..b01fb4d 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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;