UNPKG

@ledgerhq/evm-tools

Version:

EVM tooling used for coin integrations & app bindings

274 lines (235 loc) 8.42 kB
import axios from "axios"; import SHA224 from "crypto-js/sha224"; import { getEnv } from "@ledgerhq/live-env"; import { EIP712Message } from "@ledgerhq/types-live"; import { AddressZero } from "@ethersproject/constants"; import { _TypedDataEncoder as TypedDataEncoder } from "@ethersproject/hash"; import EIP712CAL from "@ledgerhq/cryptoassets-evm-signatures/data/eip712"; import EIP712CALV2 from "@ledgerhq/cryptoassets-evm-signatures/data/eip712_v2"; import { CALServiceEIP712Response, MessageFilters } from "./types"; // As defined in [spec](https://eips.ethereum.org/EIPS/eip-712), the properties below are all required. export function isEIP712Message(message: unknown): message is EIP712Message { return ( !!message && typeof message === "object" && "types" in message && "primaryType" in message && "domain" in message && "message" in message ); } export const sortObjectAlphabetically = (obj: Record<string, unknown>): Record<string, unknown> => { const keys = Object.keys(obj).sort(); return keys.reduce((acc, curr) => { const value = (() => { if (Array.isArray(obj[curr])) { return (obj[curr] as unknown[]).map(field => sortObjectAlphabetically(field as Record<string, unknown>), ); } return obj[curr]; })(); (acc as Record<string, unknown>)[curr] = value; return acc; }, {}); }; export const getSchemaHashForMessage = (message: EIP712Message): string => { const { types } = message; const sortedTypes = sortObjectAlphabetically(types); return SHA224(JSON.stringify(sortedTypes).replace(" ", "")).toString(); }; /** * Tries to find the proper filters for a given EIP712 message * in the CAL * * @param {EIP712Message} message * @returns {MessageFilters | undefined} */ export const getFiltersForMessage = async ( message: EIP712Message, shouldUseV1Filters?: boolean, calServiceURL?: string | null, ): Promise<MessageFilters | undefined> => { const schemaHash = getSchemaHashForMessage(message); const verifyingContract = message.domain?.verifyingContract?.toLowerCase() || AddressZero; try { if (calServiceURL) { const { data } = await axios.get<CALServiceEIP712Response>(`${calServiceURL}/v1/dapps`, { params: { output: "eip712_signatures", eip712_signatures_version: shouldUseV1Filters ? "v1" : "v2", chain_id: message.domain?.chainId, contracts: verifyingContract, }, }); // Rather than relying on array indices, find the right object wherever it may be, if it exists const targetObject = data.find( item => item?.eip712_signatures?.[verifyingContract]?.[schemaHash], ); const filters = targetObject?.eip712_signatures?.[verifyingContract]?.[schemaHash]; if (!filters) { // Fallback to catch throw new Error("Fallback to static file"); } return filters; } // Fallback to catch throw new Error("Fallback to static file"); } catch (e) { const messageId = `${message.domain?.chainId ?? 0}:${verifyingContract}:${schemaHash}`; if (shouldUseV1Filters) { return EIP712CAL[messageId as keyof typeof EIP712CAL]; } return EIP712CALV2[messageId as keyof typeof EIP712CALV2] as MessageFilters; } }; /** * Get the value at a specific path of an object and return it as a string or as an array of string * Used recursively by getValueFromPath * * @see getValueFromPath */ const getValue = ( path: string, value: Record<string, any> | Array<any> | string, ): Record<string, any> | Array<any> | string => { if (typeof value === "object") { if (Array.isArray(value)) { return value.map(v => getValue(path, v)).flat(); } /* istanbul ignore if : unecessary test of a throw */ if (!(path in value)) { throw new Error(`Could not find key ${path} in ${JSON.stringify(value)} `); } const result = value[path]; return typeof result === "object" ? result : result.toString(); } return value.toString(); }; /** * Using a path as a string, returns the value(s) of a json key without worrying about depth or arrays * (e.g: 'to.wallets.[]' => ["0x123", "0x456"]) */ export const getValueFromPath = (path: string, eip721Message: EIP712Message): string | string[] => { const splittedPath = path.split("."); const { message } = eip721Message; let value: any = message; for (let i = 0; i <= splittedPath.length - 1; i++) { const subPath = splittedPath[i]; const isLastElement = i >= splittedPath.length - 1; if (subPath === "[]" && !isLastElement) continue; value = getValue(subPath, value); } /* istanbul ignore if : unecessary test of a throw */ if (value === message) { throw new Error("getValueFromPath returned the whole original message"); } return value as string | string[]; }; function formatDate(timestamp: string) { const date = new Date(Number(timestamp) * 1000); if (isNaN(date.getTime())) { return timestamp; } const formatter = new Intl.DateTimeFormat("en-GB", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", timeZone: "UTC", hour12: false, }); const parts = formatter.formatToParts(date); const p = (type: string) => parts.find(p => p.type === type)?.value || "00"; return `${p("year")}-${p("month")}-${p("day")} ${p("hour")}:${p("minute")}:${p("second")} UTC`; } /** * Gets the fields visible on the nano for a specific EIP712 message */ export const getEIP712FieldsDisplayedOnNano = async ( messageData: EIP712Message, calServiceURL: string = getEnv("CAL_SERVICE_URL"), ): Promise<{ label: string; value: string | string[] }[] | null> => { if (!isEIP712Message(messageData)) { return null; } const { EIP712Domain, ...otherTypes } = messageData.types; const displayedInfos: { label: string; value: string | string[] }[] = []; const filters = await getFiltersForMessage(messageData, false, calServiceURL); if (!filters) { const { types } = messageData; const domainFields = types["EIP712Domain"].map(({ name }) => name); if (domainFields.includes("name") && messageData.domain.name) { displayedInfos.push({ label: "name", value: messageData.domain.name, }); } if (domainFields.includes("version") && messageData.domain.version) { displayedInfos.push({ label: "version", value: messageData.domain.version, }); } if (domainFields.includes("chainId") && messageData.domain.chainId) { displayedInfos.push({ label: "chainId", value: messageData.domain.chainId.toString(), }); } if (domainFields.includes("verifyingContract") && messageData.domain.verifyingContract) { displayedInfos.push({ label: "verifyingContract", value: messageData.domain.verifyingContract.toString(), }); } if (domainFields.includes("salt") && messageData.domain.salt) { displayedInfos.push({ label: "salt", value: messageData.domain.salt.toString(), }); } displayedInfos.push({ label: "Message hash", value: TypedDataEncoder.hashStruct(messageData.primaryType, otherTypes, messageData.message), }); return displayedInfos; } const { contractName, fields } = filters; if (contractName && contractName.label) { displayedInfos.push({ label: "Contract", value: contractName.label, }); } if (messageData.primaryType === "PermitSingle") { for (const field of fields) { if (field.path.includes("token")) { displayedInfos.push({ label: "Token", value: getValueFromPath(field.path, messageData), }); } else if (field.path.includes("amount")) { displayedInfos.push({ label: "Amount", value: getValueFromPath(field.path, messageData), }); } else if (field.path.includes("expiration")) { displayedInfos.push({ label: "Approval expires", value: formatDate(getValueFromPath(field.path, messageData) as string), }); } } } else { for (const field of fields) { displayedInfos.push({ label: field.label, value: getValueFromPath(field.path, messageData), }); } } return displayedInfos; };