cosmos-multisig-ui/components/forms/TransactionSigning.tsx
2024-07-22 11:31:51 +02:00

281 lines
9.3 KiB
TypeScript

import { DbSignatureObj, DbSignatureObjDraft, DbTransactionParsedDataJson } from "@/graphql";
import { createDbSignature } from "@/lib/api";
import { getKeplrAminoSigner, getKeplrKey, useKeplrReconnect } from "@/lib/keplr";
import { aminoConverters } from "@/lib/msg";
import { toastError, toastSuccess } from "@/lib/utils";
import { LoadingStates, SigningStatus } from "@/types/signing";
import { MultisigThresholdPubkey, makeCosmoshubPath } from "@cosmjs/amino";
import { wasmTypes } from "@cosmjs/cosmwasm-stargate";
import { toBase64 } from "@cosmjs/encoding";
import { LedgerSigner } from "@cosmjs/ledger-amino";
import { OfflineSigner, Registry } from "@cosmjs/proto-signing";
import { AminoTypes, SigningStargateClient, defaultRegistryTypes } from "@cosmjs/stargate";
import { assert } from "@cosmjs/utils";
import { Key } from "@keplr-wallet/types";
import TransportWebUSB from "@ledgerhq/hw-transport-webusb";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { useChains } from "../../context/ChainsContext";
import { getConnectError } from "../../lib/errorHelpers";
import HashView from "../dataViews/HashView";
import Button from "../inputs/Button";
import StackableContainer from "../layout/StackableContainer";
interface TransactionSigningProps {
readonly signatures: DbSignatureObj[];
readonly tx: DbTransactionParsedDataJson;
readonly pubkey: MultisigThresholdPubkey;
readonly transactionID: string;
readonly addSignature: (signature: DbSignatureObj) => void;
}
const TransactionSigning = (props: TransactionSigningProps) => {
const memberPubkeys = props.pubkey.value.pubkeys.map(({ value }) => value);
const { chain } = useChains();
const [walletAccount, setWalletAccount] = useState<Partial<Key>>();
const [signing, setSigning] = useState<SigningStatus>("not_signed");
const [walletType, setWalletType] = useState<"Keplr" | "Ledger">();
const [ledgerSigner, setLedgerSigner] = useState<OfflineSigner | null>(null);
const [loading, setLoading] = useState<LoadingStates>({});
const connectKeplr = useCallback(async () => {
try {
setLoading((oldLoading) => ({ ...oldLoading, keplr: true }));
const newWalletAccount = await getKeplrKey(chain.chainId);
setWalletAccount(newWalletAccount);
const pubkey = toBase64(newWalletAccount.pubKey);
const isMember = memberPubkeys.includes(pubkey);
const hasSigned = isMember
? props.signatures.some((sig) => sig.address === newWalletAccount.bech32Address)
: false;
if (!isMember) {
setSigning("not_a_member");
} else {
if (hasSigned) {
setSigning("signed");
} else {
setSigning("not_signed");
}
}
setWalletType("Keplr");
} catch (e) {
const connectError = getConnectError(e);
console.error(connectError, e);
toastError({
description: connectError,
fullError: e instanceof Error ? e : undefined,
});
} finally {
setLoading((newLoading) => ({ ...newLoading, keplr: false }));
}
}, [chain.chainId, memberPubkeys, props.signatures]);
useKeplrReconnect(!!walletAccount?.address, connectKeplr);
const connectLedger = async () => {
try {
setLoading((newLoading) => ({ ...newLoading, ledger: true }));
// Prepare ledger
const ledgerTransport = await TransportWebUSB.create(120000, 120000);
// Setup signer
const offlineSigner = new LedgerSigner(ledgerTransport, {
hdPaths: [makeCosmoshubPath(0)],
prefix: chain.addressPrefix,
});
const accounts = await offlineSigner.getAccounts();
const tempWalletAccount = {
bech32Address: accounts[0].address,
pubKey: accounts[0].pubkey,
algo: accounts[0].algo,
};
setWalletAccount(tempWalletAccount);
const pubkey = toBase64(tempWalletAccount.pubKey);
const isMember = memberPubkeys.includes(pubkey);
const hasSigned = isMember
? props.signatures.some((sig) => sig.address === tempWalletAccount.bech32Address)
: false;
if (!isMember) {
setSigning("not_a_member");
} else {
if (hasSigned) {
setSigning("signed");
} else {
setSigning("not_signed");
}
}
setLedgerSigner(offlineSigner);
setWalletType("Ledger");
} catch (e) {
const connectError = getConnectError(e);
console.error(connectError, e);
toastError({
description: connectError,
fullError: e instanceof Error ? e : undefined,
});
} finally {
setLoading((newLoading) => ({ ...newLoading, ledger: false }));
}
};
const signTransaction = async () => {
const loadingToastId = toast.loading("Signing transaction");
try {
setLoading((newLoading) => ({ ...newLoading, signing: true }));
const offlineSigner =
walletType === "Keplr" ? await getKeplrAminoSigner(chain.chainId) : ledgerSigner;
if (!offlineSigner) {
throw new Error("Offline signer not found");
}
const signerAddress = walletAccount?.bech32Address;
assert(signerAddress, "Missing signer address");
const signingClient = await SigningStargateClient.offline(offlineSigner, {
registry: new Registry([...defaultRegistryTypes, ...wasmTypes]),
aminoTypes: new AminoTypes(aminoConverters),
});
const signerData = {
accountNumber: props.tx.accountNumber,
sequence: props.tx.sequence,
chainId: 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) {
throw new Error("This account has already signed");
}
const signature: Omit<DbSignatureObjDraft, "transaction"> = {
bodyBytes: bases64EncodedBodyBytes,
signature: bases64EncodedSignature,
address: signerAddress,
};
await createDbSignature(props.transactionID, signature);
toastSuccess("Transaction signed by", signerAddress);
props.addSignature(signature);
setSigning("signed");
} catch (e) {
console.error("Failed to sign the tx:", e);
toastError({
description: "Failed to sign the tx",
fullError: e instanceof Error ? e : undefined,
});
} finally {
setLoading((newLoading) => ({ ...newLoading, signing: false }));
toast.dismiss(loadingToastId);
}
};
return (
<>
<StackableContainer lessPadding lessMargin lessRadius>
<h2>Current Signers</h2>
{props.signatures.map((signature, i) => (
<StackableContainer lessPadding lessRadius lessMargin key={`${signature.address}_${i}`}>
<HashView hash={signature.address} />
</StackableContainer>
))}
{!props.signatures.length ? <p>No signatures yet</p> : null}
</StackableContainer>
<StackableContainer lessPadding lessMargin lessRadius>
{signing === "signed" ? (
<div className="confirmation">
<svg viewBox="0 0 77 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 30L26 51L72 5" stroke="white" strokeWidth="12" />
</svg>
<p>You've signed this transaction</p>
</div>
) : null}
{signing === "not_a_member" ? (
<div className="multisig-error">
<p>You don't belong to this multisig</p>
</div>
) : null}
{signing === "not_signed" ? (
<>
{walletAccount ? (
<>
<p>
You can sign this transaction with {walletAccount.bech32Address} (
{walletType ?? "Unknown wallet type"})
</p>
<Button
label="Sign transaction"
onClick={signTransaction}
loading={loading.signing}
/>
</>
) : (
<>
<h2>Choose wallet to sign</h2>
<Button label="Connect Keplr" onClick={connectKeplr} loading={loading.keplr} />
<Button
label="Connect Ledger (WebUSB)"
onClick={connectLedger}
loading={loading.ledger}
/>
</>
)}
</>
) : null}
</StackableContainer>
<style jsx>{`
p {
text-align: center;
max-width: none;
}
h2 {
margin-top: 1em;
}
h2:first-child {
margin-top: 0;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
.confirmation {
display: flex;
justify-content: center;
}
.confirmation svg {
height: 0.8em;
margin-right: 0.5em;
}
.multisig-error p {
color: red;
font-size: 16px;
}
`}</style>
</>
);
};
export default TransactionSigning;