Merge branch 'master' into fix/use-sig-crash

This commit is contained in:
abefernan
2023-04-19 10:51:20 +02:00
11 changed files with 311 additions and 152 deletions
+30
View File
@@ -0,0 +1,30 @@
interface SpinnerProps {
readonly size?: number;
}
const Spinner = ({ size }: SpinnerProps) => (
<>
<div className="spinner"></div>
<style jsx>{`
.spinner {
border: ${size || 2}px solid #f3f3f3;
border-top: ${size || 2}px solid #561253;
border-radius: 50%;
width: ${size || 16}px;
height: ${size || 16}px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
`}</style>
</>
);
export default Spinner;
+55 -43
View File
@@ -8,7 +8,12 @@ import Button from "../inputs/Button";
import Input from "../inputs/Input";
import Select from "../inputs/Select";
import StackableContainer from "../layout/StackableContainer";
import { ChainRegistryAsset } from "./chainregistry";
import {
RegistryChainApisRpc,
RegistryChainExplorer,
getAssetsFromRegistry,
getChainFromRegistry,
} from "./chainregistry";
interface ChainOption {
label: string;
@@ -112,76 +117,83 @@ const ChainSelect = () => {
const getChainInfo = async (chainOption: GithubChainRegistryItem) => {
setChainError(null);
try {
const chainInfoUrl =
"https://cdn.jsdelivr.net/gh/cosmos/chain-registry@master/" +
chainOption.path +
"/chain.json";
const chainAssetUrl =
"https://cdn.jsdelivr.net/gh/cosmos/chain-registry@master/" +
chainOption.path +
"/assetlist.json";
const chainData = await getChainFromRegistry(chainOption.path);
const assets = await getAssetsFromRegistry(chainOption.path);
const firstAsset = assets[0];
const { data: chainData } = await axios.get(chainInfoUrl);
const { data: assetData } = await axios.get(chainAssetUrl);
const nodeAddress = getNodeFromArray(chainData.apis.rpc);
const addressPrefix = chainData["bech32_prefix"];
const chainId = chainData["chain_id"];
const chainDisplayName = chainData["pretty_name"];
const registryName = chainOption.name;
const nodeAddress = await getNodeFromArray(chainData.apis.rpc);
const explorerLink = getExplorerFromArray(chainData.explorers);
const denom = firstAsset.base || "";
const displayDenom = firstAsset.symbol || "";
const firstAsset: ChainRegistryAsset | undefined = assetData.assets?.[0];
const denom = firstAsset?.base || "";
const displayDenom = firstAsset?.symbol || "";
const gasPrice = firstAsset ? `0.03${firstAsset.base}` : "";
const displayUnit = firstAsset?.denom_units.find((u) => u.denom == firstAsset.display);
const displayUnit = firstAsset.denom_units.find((u) => u.denom == firstAsset.display);
const displayDenomExponent = displayUnit?.exponent ?? 6;
// test client connection
const client = await StargateClient.connect(nodeAddress);
await client.getHeight();
const feeToken = chainData.fees.fee_tokens.find((token) => token.denom == denom) ?? { denom };
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}` : "";
// change app state
dispatch({
type: "changeChain",
value: {
registryName: chainOption.name,
addressPrefix: chainData.bech32_prefix,
chainId: chainData.chain_id,
chainDisplayName: chainData.pretty_name,
nodeAddress,
explorerLink,
denom,
displayDenom,
displayDenomExponent,
gasPrice,
chainId,
chainDisplayName,
registryName,
addressPrefix,
explorerLink,
gasPrice: formattedGasPrice,
},
});
setShowSettings(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.log(error);
} catch (error) {
if (error instanceof Error) {
setChainError(error.message);
} else {
setChainError("Error getting chain info");
}
console.error("Error getting chain info", error);
setShowSettings(true);
setChainError(error.toString());
}
};
const getExplorerFromArray = (array: { kind: string; url: string; tx_page: string }[]) => {
if (array && array.length > 0) {
return array[0]["tx_page"];
}
return "";
const getExplorerFromArray = (explorers: readonly RegistryChainExplorer[]) => {
return explorers[0]?.tx_page ?? "";
};
const getNodeFromArray = (nodeArray: { address: string; provider: string }[]) => {
const getNodeFromArray = async (nodeArray: readonly RegistryChainApisRpc[]) => {
// only return https connections
const secureNodes = nodeArray.filter((node) => node.address.includes("https://"));
const secureNodes = nodeArray
.filter((node) => node.address.startsWith("https://"))
.map(({ address }) => address);
if (secureNodes.length === 0) {
throw new Error("No SSL enabled RPC nodes available for this chain");
}
return secureNodes[0].address;
for (const node of secureNodes) {
try {
// test client connection
const client = await StargateClient.connect(node);
await client.getHeight();
return node;
} catch {}
}
throw new Error("No RPC nodes available for this chain");
};
const onChainSelect = (option: ChainOption) => {
+70 -3
View File
@@ -1,7 +1,49 @@
import axios from "axios";
export interface RegistryChainApisRpc {
readonly address: string;
readonly provider: string;
}
export interface RegistryChainApis {
readonly rpc: readonly RegistryChainApisRpc[];
}
export interface RegistryChainExplorer {
readonly kind: string;
readonly url: string;
readonly tx_page: string;
}
export interface RegistryChainFeeTokens {
readonly denom: string;
readonly average_gas_price?: number;
readonly low_gas_price?: number;
readonly high_gas_price?: number;
readonly fixed_min_gas_price?: number;
}
export interface RegistryChainFees {
readonly fee_tokens: readonly RegistryChainFeeTokens[];
}
export interface RegistryChain {
readonly apis: RegistryChainApis;
readonly bech32_prefix: string;
readonly chain_id: string;
readonly explorers: readonly RegistryChainExplorer[];
readonly fees: RegistryChainFees;
readonly pretty_name: string;
}
export interface RegistryChainResponse {
readonly data: RegistryChain;
}
/**
* See https://github.com/cosmos/chain-registry/blob/1e9ecde770951cab90f0853a624411d79af90b83/provenance/assetlist.json#L8-L12
*/
export interface ChainRegistryDemonUnit {
export interface RegistryAssetDenomUnit {
denom: string;
exponent: number;
aliases: string[];
@@ -10,9 +52,9 @@ export interface ChainRegistryDemonUnit {
/**
* See https://github.com/cosmos/chain-registry/blob/1e9ecde770951cab90f0853a624411d79af90b83/provenance/assetlist.json#L5-L28
*/
export interface ChainRegistryAsset {
export interface RegistryAsset {
description: string;
denom_units: ChainRegistryDemonUnit[];
denom_units: RegistryAssetDenomUnit[];
base: string;
name: string;
display: string;
@@ -23,3 +65,28 @@ export interface ChainRegistryAsset {
};
coingecko_id: string;
}
export interface RegistryAssetsResponse {
readonly data: { readonly assets: readonly RegistryAsset[] };
}
const registryGhUrl = "https://cdn.jsdelivr.net/gh/cosmos/chain-registry@master/";
export const getChainFromRegistry = async (chainGhName: string): Promise<RegistryChain> => {
const chainGhUrl = registryGhUrl + chainGhName + "/chain.json";
const { data: chain }: RegistryChainResponse = await axios.get(chainGhUrl);
return chain;
};
export const getAssetsFromRegistry = async (
chainGhName: string,
): Promise<readonly RegistryAsset[]> => {
const assetsGhUrl = registryGhUrl + chainGhName + "/assetlist.json";
const {
data: { assets },
}: RegistryAssetsResponse = await axios.get(assetsGhUrl);
return assets;
};
+3 -3
View File
@@ -3,7 +3,7 @@ import { Account, calculateFee } from "@cosmjs/stargate";
import { assert } from "@cosmjs/utils";
import axios from "axios";
import { NextRouter, withRouter } from "next/router";
import React, { useState } from "react";
import { useState } from "react";
import { useAppContext } from "../../context/AppContext";
import { checkAddress, exampleValidatorAddress } from "../../lib/displayHelpers";
import Button from "../inputs/Button";
@@ -24,7 +24,7 @@ const DelegationForm = (props: Props) => {
const [memo, setMemo] = useState("");
const [gas, setGas] = useState(200000);
const [gasPrice, _setGasPrice] = useState(state.chain.gasPrice);
const [_processing, setProcessing] = useState(false);
const [processing, setProcessing] = useState(false);
const [addressError, setAddressError] = useState("");
const createTransaction = (txValidatorAddress: string, txAmount: string, gasLimit: number) => {
@@ -130,7 +130,7 @@ const DelegationForm = (props: Props) => {
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMemo(e.target.value)}
/>
</div>
<Button label="Delegate" onClick={handleCreate} />
<Button label="Delegate" onClick={handleCreate} loading={processing} />
<style jsx>{`
p {
margin-top: 15px;
+9 -10
View File
@@ -1,15 +1,14 @@
import React, { useState } from "react";
import { withRouter, NextRouter } from "next/router";
import { StargateClient } from "@cosmjs/stargate";
import { assert } from "@cosmjs/utils";
import { NextRouter, withRouter } from "next/router";
import { useState } from "react";
import { useAppContext } from "../../context/AppContext";
import Button from "../inputs/Button";
import { createMultisigFromCompressedSecp256k1Pubkeys } from "../../lib/multisigHelpers";
import Input from "../inputs/Input";
import StackableContainer from "../layout/StackableContainer";
import ThresholdInput from "../inputs/ThresholdInput";
import { exampleAddress, examplePubkey } from "../../lib/displayHelpers";
import { createMultisigFromCompressedSecp256k1Pubkeys } from "../../lib/multisigHelpers";
import Button from "../inputs/Button";
import Input from "../inputs/Input";
import ThresholdInput from "../inputs/ThresholdInput";
import StackableContainer from "../layout/StackableContainer";
const emptyPubKeyGroup = () => {
return { address: "", compressedPubkey: "", keyError: "", isPubkey: false };
@@ -23,7 +22,7 @@ const MultiSigForm = (props: Props) => {
const { state } = useAppContext();
const [pubkeys, setPubkeys] = useState([emptyPubKeyGroup(), emptyPubKeyGroup()]);
const [threshold, setThreshold] = useState(2);
const [_processing, setProcessing] = useState(false);
const [processing, setProcessing] = useState(false);
const handleChangeThreshold = (e: React.ChangeEvent<HTMLInputElement>) => {
let newThreshold = parseInt(e.target.value, 10);
@@ -195,7 +194,7 @@ const MultiSigForm = (props: Props) => {
</p>
</StackableContainer>
</StackableContainer>
<Button primary onClick={handleCreate} label="Create Multisig" />
<Button primary onClick={handleCreate} label="Create Multisig" loading={processing} />
<style jsx>{`
.key-inputs {
display: flex;
+2 -2
View File
@@ -25,7 +25,7 @@ const ReDelegationForm = (props: Props) => {
const [memo, setMemo] = useState("");
const [gas, setGas] = useState(300000);
const [gasPrice, _setGasPrice] = useState(state.chain.gasPrice);
const [_processing, setProcessing] = useState(false);
const [processing, setProcessing] = useState(false);
const [addressErrors, setAddressErrors] = useState({ src: "", dst: "" });
const createTransaction = (
@@ -158,7 +158,7 @@ const ReDelegationForm = (props: Props) => {
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMemo(e.target.value)}
/>
</div>
<Button label="ReDelegate" onClick={handleCreate} />
<Button label="ReDelegate" onClick={handleCreate} loading={processing} />
<style jsx>{`
p {
margin-top: 15px;
+2 -2
View File
@@ -22,7 +22,7 @@ const RewardsForm = (props: Props) => {
const [memo, setMemo] = useState("");
const [gas, setGas] = useState(200000);
const [gasPrice, _setGasPrice] = useState(state.chain.gasPrice);
const [_processing, setProcessing] = useState(false);
const [processing, setProcessing] = useState(false);
const [addressError, setAddressError] = useState("");
const createTransaction = (txValidatorAddress: string, gasLimit: number) => {
@@ -111,7 +111,7 @@ const RewardsForm = (props: Props) => {
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMemo(e.target.value)}
/>
</div>
<Button label="Claim Rewards" onClick={handleCreate} />
<Button label="Claim Rewards" onClick={handleCreate} loading={processing} />
<style jsx>{`
p {
margin-top: 15px;
+7 -8
View File
@@ -1,15 +1,14 @@
import axios from "axios";
import { Account, calculateFee } from "@cosmjs/stargate";
import { Decimal } from "@cosmjs/math";
import { Account, calculateFee } from "@cosmjs/stargate";
import { assert } from "@cosmjs/utils";
import React, { useState } from "react";
import { withRouter, NextRouter } from "next/router";
import axios from "axios";
import { NextRouter, withRouter } from "next/router";
import { useState } from "react";
import { useAppContext } from "../../context/AppContext";
import { checkAddress, exampleAddress } from "../../lib/displayHelpers";
import Button from "../inputs/Button";
import Input from "../inputs/Input";
import StackableContainer from "../layout/StackableContainer";
import { checkAddress, exampleAddress } from "../../lib/displayHelpers";
interface Props {
address: string | null;
@@ -25,7 +24,7 @@ const TransactionForm = (props: Props) => {
const [memo, setMemo] = useState("");
const [gas, setGas] = useState(200000);
const [gasPrice, _setGasPrice] = useState(state.chain.gasPrice);
const [_processing, setProcessing] = useState(false);
const [processing, setProcessing] = useState(false);
const [addressError, setAddressError] = useState("");
const createTransaction = (txToAddress: string, txAmount: string, gasLimit: number) => {
@@ -130,7 +129,7 @@ const TransactionForm = (props: Props) => {
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMemo(e.target.value)}
/>
</div>
<Button label="Create Transaction" onClick={handleCreate} />
<Button label="Create Transaction" onClick={handleCreate} loading={processing} />
<style jsx>{`
p {
margin-top: 15px;
+111 -75
View File
@@ -1,18 +1,22 @@
import React, { useState } from "react";
import axios from "axios";
import { makeCosmoshubPath } from "@cosmjs/amino";
import { toBase64 } from "@cosmjs/encoding";
import { LedgerSigner } from "@cosmjs/ledger-amino";
import { SigningStargateClient } from "@cosmjs/stargate";
import { assert } from "@cosmjs/utils";
import { useAppContext } from "../../context/AppContext";
import Button from "../inputs/Button";
import HashView from "../dataViews/HashView";
import StackableContainer from "../layout/StackableContainer";
import { DbSignature, DbTransaction, WalletAccount } from "../../types";
import TransportWebUSB from "@ledgerhq/hw-transport-webusb";
import { LedgerSigner } from "@cosmjs/ledger-amino";
import { makeCosmoshubPath } from "@cosmjs/amino";
import axios from "axios";
import { useState } from "react";
import { useAppContext } from "../../context/AppContext";
import { DbSignature, DbTransaction, WalletAccount } from "../../types";
import HashView from "../dataViews/HashView";
import Button from "../inputs/Button";
import StackableContainer from "../layout/StackableContainer";
interface LoadingStates {
readonly signing?: boolean;
readonly keplr?: boolean;
readonly ledger?: boolean;
}
interface Props {
signatures: DbSignature[];
@@ -28,10 +32,13 @@ const TransactionSigning = (props: Props) => {
const [hasSigned, setHasSigned] = useState(false);
const [walletType, setWalletType] = useState<"Keplr" | "Ledger">();
const [ledgerSigner, setLedgerSigner] = useState({});
const [loading, setLoading] = useState<LoadingStates>({});
const connectKeplr = async () => {
try {
setLoading((oldLoading) => ({ ...oldLoading, keplr: true }));
assert(state.chain.chainId, "chainId missing");
await window.keplr.enable(state.chain.chainId);
window.keplr.defaultOptions = {
sign: { preferNoSetFee: true, preferNoSetMemo: true, disableBalanceCheck: true },
@@ -45,81 +52,102 @@ const TransactionSigning = (props: Props) => {
setHasSigned(tempHasSigned);
setWalletType("Keplr");
} catch (e) {
console.log("enable err: ", e);
console.log("enable keplr err: ", e);
} finally {
setLoading((newLoading) => ({ ...newLoading, keplr: false }));
}
};
const connectLedger = async () => {
assert(state.chain.addressPrefix, "addressPrefix missing");
try {
setLoading((newLoading) => ({ ...newLoading, ledger: true }));
assert(state.chain.addressPrefix, "addressPrefix missing");
// Prepare ledger
const ledgerTransport = await TransportWebUSB.create(120000, 120000);
// Prepare ledger
const ledgerTransport = await TransportWebUSB.create(120000, 120000);
// Setup signer
const offlineSigner = new LedgerSigner(ledgerTransport, {
hdPaths: [makeCosmoshubPath(0)],
prefix: state.chain.addressPrefix,
});
console.log(offlineSigner);
const accounts = await offlineSigner.getAccounts();
console.log(accounts);
const tempWalletAccount: WalletAccount = {
bech32Address: accounts[0].address,
pubkey: accounts[0].pubkey,
algo: accounts[0].algo,
};
// Setup signer
const offlineSigner = new LedgerSigner(ledgerTransport, {
hdPaths: [makeCosmoshubPath(0)],
prefix: state.chain.addressPrefix,
});
console.log(offlineSigner);
const accounts = await offlineSigner.getAccounts();
console.log(accounts);
const tempWalletAccount: WalletAccount = {
bech32Address: accounts[0].address,
pubkey: accounts[0].pubkey,
algo: accounts[0].algo,
};
const tempHasSigned = props.signatures.some(
(sig) => sig.address === tempWalletAccount.bech32Address,
);
setWalletAccount(tempWalletAccount);
setHasSigned(tempHasSigned);
setLedgerSigner(offlineSigner);
setWalletType("Ledger");
const tempHasSigned = props.signatures.some(
(sig) => sig.address === tempWalletAccount.bech32Address,
);
setWalletAccount(tempWalletAccount);
setHasSigned(tempHasSigned);
setLedgerSigner(offlineSigner);
setWalletType("Ledger");
} catch (e) {
console.log("enable ledger err: ", e);
} finally {
setLoading((newLoading) => ({ ...newLoading, ledger: false }));
}
};
const signTransaction = async () => {
assert(state.chain.chainId, "chainId missing");
try {
setLoading((newLoading) => ({ ...newLoading, signing: true }));
assert(state.chain.chainId, "chainId missing");
const offlineSigner =
walletType === "Keplr" ? window.getOfflineSignerOnlyAmino(state.chain.chainId) : ledgerSigner;
const offlineSigner =
walletType === "Keplr"
? window.getOfflineSignerOnlyAmino(state.chain.chainId)
: ledgerSigner;
const signerAddress = walletAccount?.bech32Address;
assert(signerAddress, "Missing signer address");
const signingClient = await SigningStargateClient.offline(offlineSigner);
const signerAddress = walletAccount?.bech32Address;
assert(signerAddress, "Missing signer address");
const signingClient = await SigningStargateClient.offline(offlineSigner);
const signerData = {
accountNumber: props.tx.accountNumber,
sequence: props.tx.sequence,
chainId: state.chain.chainId,
};
const { bodyBytes, signatures } = await signingClient.sign(
signerAddress,
props.tx.msgs,
props.tx.fee,
props.tx.memo,
signerData,
);
// check existing signatures
const bases64EncodedSignature = toBase64(signatures[0]);
const bases64EncodedBodyBytes = toBase64(bodyBytes);
const prevSigMatch = props.signatures.findIndex(
(signature) => signature.signature === bases64EncodedSignature,
);
if (prevSigMatch > -1) {
setSigError("This account has already signed.");
} else {
const signature = {
bodyBytes: bases64EncodedBodyBytes,
signature: bases64EncodedSignature,
address: signerAddress,
const signerData = {
accountNumber: props.tx.accountNumber,
sequence: props.tx.sequence,
chainId: state.chain.chainId,
};
const _res = await axios.post(`/api/transaction/${props.transactionID}/signature`, signature);
props.addSignature(signature);
setHasSigned(true);
const { bodyBytes, signatures } = await signingClient.sign(
signerAddress,
props.tx.msgs,
props.tx.fee,
props.tx.memo,
signerData,
);
// check existing signatures
const bases64EncodedSignature = toBase64(signatures[0]);
const bases64EncodedBodyBytes = toBase64(bodyBytes);
const prevSigMatch = props.signatures.findIndex(
(signature) => signature.signature === bases64EncodedSignature,
);
if (prevSigMatch > -1) {
setSigError("This account has already signed.");
} else {
const signature = {
bodyBytes: bases64EncodedBodyBytes,
signature: bases64EncodedSignature,
address: signerAddress,
};
const _res = await axios.post(
`/api/transaction/${props.transactionID}/signature`,
signature,
);
props.addSignature(signature);
setHasSigned(true);
}
} catch (e) {
console.log("signing err: ", e);
} finally {
setLoading((newLoading) => ({ ...newLoading, signing: false }));
}
};
@@ -144,12 +172,20 @@ const TransactionSigning = (props: Props) => {
Connected signer {walletAccount.bech32Address} (
{walletType ?? "Unknown wallet type"}).
</p>
<Button label="Sign transaction" onClick={signTransaction} />
<Button
label="Sign transaction"
onClick={signTransaction}
loading={loading.signing}
/>
</>
) : (
<>
<Button label="Connect Keplr" onClick={connectKeplr} />
<Button label="Connect Ledger (WebUSB)" onClick={connectLedger} />
<Button label="Connect Keplr" onClick={connectKeplr} loading={loading.keplr} />
<Button
label="Connect Ledger (WebUSB)"
onClick={connectLedger}
loading={loading.ledger}
/>
</>
)}
</StackableContainer>
+2 -2
View File
@@ -24,7 +24,7 @@ const UnDelegationForm = (props: Props) => {
const [memo, setMemo] = useState("");
const [gas, setGas] = useState(300000);
const [gasPrice, _setGasPrice] = useState(state.chain.gasPrice);
const [_processing, setProcessing] = useState(false);
const [processing, setProcessing] = useState(false);
const [addressError, setAddressError] = useState("");
const createTransaction = (txValidatorAddress: string, txAmount: string, gasLimit: number) => {
@@ -130,7 +130,7 @@ const UnDelegationForm = (props: Props) => {
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMemo(e.target.value)}
/>
</div>
<Button label="UnDelegate" onClick={handleCreate} />
<Button label="UnDelegate" onClick={handleCreate} loading={processing} />
<style jsx>{`
p {
margin-top: 15px;
+20 -4
View File
@@ -1,7 +1,8 @@
import React from "react";
import Spinner from "../Spinner";
interface Props {
primary?: boolean;
loading?: boolean;
disabled?: boolean;
href?: string;
label: string;
@@ -17,10 +18,18 @@ const Button = (props: Props) => (
) : (
<button
className={props.primary ? "primary button" : "button"}
onClick={props.onClick}
onClick={props.disabled || props.loading ? () => {} : props.onClick}
disabled={props.disabled}
data-loading={props.loading}
>
{props.label}
{props.loading ? (
<div className="button-cluster">
<Spinner />
{props.label}
</div>
) : (
props.label
)}
</button>
)}
<style jsx>{`
@@ -44,10 +53,17 @@ const Button = (props: Props) => (
button:first-child {
margin-top: 0;
}
button:disabled {
button:disabled,
button[data-loading="true"] {
opacity: 0.5;
cursor: initial;
}
.button-cluster {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
`}</style>
</>
);