@ledgerhq/evm-tools
Version:
EVM tooling used for coin integrations & app bindings
274 lines (235 loc) • 8.42 kB
text/typescript
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;
};