import type { EbsiUriComponents } from "@cef-ebsi/ebsi-uri"; import type { AnySchemaObject } from "ajv"; import type { AxiosResponse, RawAxiosRequestHeaders } from "axios"; import type { JsonWebKey } from "did-resolver"; import { parse, parseUrl, serialize } from "@cef-ebsi/ebsi-uri"; import { keccak_256 } from "@noble/hashes/sha3"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; import axios, { isAxiosError } from "axios"; import memoize from "memoize"; import { fromString, toString } from "uint8arrays"; import type { EbsiEnvConfiguration, Service } from "./types.ts"; import { ValidationError } from "./errors/ValidationError.ts"; export const AXIOS_TIMEOUT = 15_000; // Cache Axios GET requests for 5 minutes const GET_MAX_AGE = 5 * 60 * 1000; const get = ( url: string, timeout = AXIOS_TIMEOUT, axiosHeaders?: RawAxiosRequestHeaders, ) => { return axios.get(url, { headers: { ...axiosHeaders, }, timeout, }); }; export const memoizedGet = memoize(get, { maxAge: GET_MAX_AGE }); function isRetryableError(error: unknown) { return ( isAxiosError(error) && error.code !== "ECONNABORTED" && (!error.response || error.response.status === 429 || (error.response.status >= 500 && error.response.status <= 599)) ); } /** * Fetches an EBSI resource. * @param ebsiUriComponents - EBSI URI components * @param config - EBSI environment configuration * @param timeout - Axios requests timeout (default: 15 seconds) * @param cache - Use cache (default: false) * @param axiosHeaders - Custom Axios request headers * @returns An Axios response containing the fetched resource. */ export const getEbsiResource = async ( ebsiUriComponents: EbsiUriComponents, config: EbsiEnvConfiguration, timeout = AXIOS_TIMEOUT, cache = false, axiosHeaders?: RawAxiosRequestHeaders, ): Promise> => { const { network, resource, service } = ebsiUriComponents; if (!Array.isArray(config.hosts) || config.hosts.length === 0) { throw new Error("At least 1 trusted host must be defined"); } if (network !== config.network.name) { throw new Error( `Network mismatch: ${serialize(ebsiUriComponents, config)} is not part of the ${config.network.name} network`, ); } const services = config.services; const serviceVersion = services[service]; const pathname = `/${service}/${serviceVersion}${resource}`; try { const response = await (cache ? memoizedGet : get)( `https://${config.hosts[0]!}${pathname}`, timeout, axiosHeaders, ); return response; } catch (error) { if (isRetryableError(error) && config.hosts.length > 1) { // Retry with next host return getEbsiResource( ebsiUriComponents, { ...config, hosts: config.hosts.slice(1), }, timeout, cache, axiosHeaders, ); } throw error; } }; /** * Checks if 2 JWKs are equal * @param jwk1 - First JWK * @param jwk2 - Second JWK */ export function equalJWKs(jwk1: JsonWebKey, jwk2: JsonWebKey): boolean { if (jwk1.kty !== jwk2.kty) return false; switch (jwk1.kty) { case "EC": { return jwk1.crv === jwk2.crv && jwk1.x === jwk2.x && jwk1.y === jwk2.y; } case "Ed25519": { return jwk1.crv === jwk2.crv && jwk1.x === jwk2.x; } case "RSA": { return jwk1.n === jwk2.n && jwk1.e === jwk2.e; } default: { throw new Error(`kty ${jwk1.kty} not supported`); } } } /** * @param error - Error to get message from */ export function getErrorMessage(error: unknown): string { if (!(error instanceof Error)) { return "unknown error"; } if (!isAxiosError(error)) { return error.message; } if (!error.response?.data) { return error.message; } if (typeof error.response.data === "string") { return error.response.data; } if (typeof error.response.data !== "object") { return "unknown error"; } const { data } = error.response; if ("detail" in data && typeof data.detail === "string") { return data.detail; } if ("title" in data && typeof data.title === "string") { return data.title; } return JSON.stringify(data); } /** * Get Ethereum address from JWK * @param jwk - Public key JWK from which to get the address */ export function getEthereumAddress(jwk: JsonWebKey): string { const publicKey = getPublicKeyHex(jwk); if (!publicKey) return ""; // removing "0x04" const publicKeyHex = publicKey.slice(4); const address = `0x${bytesToHex(keccak_256(hexToBytes(publicKeyHex))).slice(-40)}`; return toChecksumAddress(address); } /** * Transforms public key JWK to hex * @param jwk - Public key JWK to transform to hex */ export function getPublicKeyHex(jwk: JsonWebKey): string { if (jwk.crv !== "secp256k1" || !jwk.x || !jwk.y) return ""; return `0x04${toString(fromString(jwk.x, "base64url"), "hex").padStart(64, "0")}${toString(fromString(jwk.y, "base64url"), "hex").padStart(64, "0")}`; } /** * Load remote schemas with Axios * @param config - EBSI environment configuration * @param timeout - Axios requests timeout * @param axiosHeaders - Custom Axios request headers */ export function loadSchema( // TODO: propagate config: EbsiEnvConfiguration, timeout?: number, axiosHeaders?: RawAxiosRequestHeaders, ) { return async (url: string): Promise => { let ebsiUriComponents: EbsiUriComponents; try { ebsiUriComponents = url.startsWith(`${config.scheme}:`) ? parse(url, config) : parseUrl(url, config); } catch (error) { throw new ValidationError( `Error while parsing schema URL ${url}. ${error instanceof Error ? error.message : "Reason: unknown"}`, ); } // Ensure that the service is Trusted Schemas Registry if (ebsiUriComponents.service !== "trusted-schemas-registry") { throw new ValidationError( `Service mismatch: ${url} is not part of the trusted-schemas-registry service`, ); } // Load resource from one of the trusted hosts const { data: schema } = await getEbsiResource( ebsiUriComponents, config, timeout, true, // Cache results for 5 minutes axiosHeaders, ); return schema; }; } /** * Transforms address to checksum address * @param address - Address to transform */ export function toChecksumAddress(address: string) { const addr = address.toLowerCase().replace(/^0x/, ""); const hash = bytesToHex(keccak_256(addr)); let checksumAddress = "0x"; // eslint-disable-next-line unicorn/no-for-loop for (let i = 0; i < addr.length; i++) { const element = addr[i]!; checksumAddress += Number.parseInt(hash[i]!, 16) >= 8 ? element.toUpperCase() : element; } return checksumAddress; }