import { ethers } from "ethers"; import md5 from "md5"; import { BigNumber } from "ethers"; import multibase from 'multibase'; import multihashes from 'multihashes'; import EthersSafe, { EthersAdapter, TransactionResult } from "@gnosis.pm/safe-core-sdk"; import type { SafeSignature } from "@gnosis.pm/safe-core-sdk-types"; import type { Config } from '@app/config'; import config from "@app/config.json"; import { assert } from '@app/error'; import type { EnsProfile } from "@app/base/registrations/registrar"; import { getAvatar, getSeed, getAnchorsAccount, getRegistration } from '@app/base/registrations/registrar'; import type { BasicProfile } from '@datamodels/identity-profile-basic'; import { ProfileType } from '@app/profile'; import { parseUnits } from "@ethersproject/units"; import { GetSafe } from "@app/base/orgs/Org"; export enum AddressType { Contract, Org, Safe, EOA, } export interface Safe { address: string; owners: string[]; threshold: number; } export interface SafeTransaction { to: string; value: string; data: string; operation: number; } export interface Token { name: string; symbol: string; logo: string; decimals: number; balance: BigNumber; } export enum Status { Signing, Pending, Success, Failed, } export type State = { status: Status.Signing } | { status: Status.Pending } | { status: Status.Success } | { status: Status.Failed; error: string }; export async function isReverseRecordSet(address: string, domain: string, config: Config): Promise { const name = await config.provider.lookupAddress(address); return name === domain; } export async function toClipboard(text: string): Promise { return navigator.clipboard.writeText(text); } export function setOpenGraphMetaTag(data: { prop: string; content: string; attr?: string }[]): void { const elements = Array.from(document.querySelectorAll(`meta`)); elements.forEach((element: any) => { const foundElement = data.find(data => { return data.prop === element.getAttribute(data.attr || 'property'); }); if (foundElement) element.content = foundElement.content; }); } export function toWei(amount: string): BigNumber { return parseUnits(amount); } export function isAddressEqual(left: string, right: string): boolean { return left.toLowerCase() === right.toLowerCase(); } export function formatSeedAddress(id: string, host: string, port: number): string { return `${id}@${host}:${port}`; } export function formatLocationHash(hash: string | null): number | null { if (hash && hash.match(/^#L[0-9]+$/)) return parseInt(hash.slice(2)); return null; } export function formatSeedId(id: string): string { return id.substring(0, 6) + '…' + id.substring(id.length - 6, id.length); } export function removePrefix(hash: string): string { if (! hash.startsWith("0x")) { return hash; } return hash.substring(2); } export function formatRadicleUrn(id: string): string { assert(isRadicleId(id)); return id.substring(0, 14) + '…' + id.substring(id.length - 6, id.length); } export function formatBalance(n: BigNumber, decimals?: number): string { return ethers.utils.commify(parseFloat(ethers.utils.formatUnits(n, decimals)).toFixed(2)); } export function formatCAIP10Address(address: string, protocol: string, impl: number): string { return `${address.toLowerCase()}@${protocol}:${impl.toString()}`; } // Returns a checksummed, shortened, without 0x prefix Ethereum address export function formatAddress(input: string): string { const addr = ethers.utils.getAddress(input).replace(/^0x/, ""); return addr.substring(0, 4) + ' – ' + addr.substring(addr.length - 4, addr.length); } export function formatIpfsFile(ipfs: string | undefined): string | undefined { if (ipfs) return `${config.ipfs.gateway}${ipfs.replace("ipfs://", "")}`; return undefined; } // If the string is less than 10 characters the entire string is returned. export function formatHash(hash: string): string { if (hash.length < 10) return hash; return hash.substring(0, 6) + '...' + hash.substring(hash.length - 4, hash.length); } export function formatCommit(oid: string): string { return oid.substring(0, 7); } export function formatProfile(input: string, config: Config): string { if (isAddress(input)) { return ethers.utils.getAddress(input); } else { return parseEnsLabel(input, config); } } export function capitalize(s: string): string { if (s === "") return s; return s[0].toUpperCase() + s.substring(1); } // Takes a domain name, eg. 'cloudhead.radicle.eth' and returns the label, eg. 'cloudhead'. export function parseEnsLabel(name: string, config: Config): string { const domain = config.registrar.domain.replace(".", "\\."); const label = name.replace(new RegExp(`\\.${domain}$`), ""); return label; } export function clickOutside(node: HTMLElement, onEventFunction: () => void): any { const handleClick = (event: any) => { const path = event.composedPath(); if (! path.includes(node)) { onEventFunction(); } }; document.addEventListener("click", handleClick, true); return { destroy() { document.removeEventListener("click", handleClick, true); } }; } // Get the mime type of an image, given a file path. // Returns `null` if unknown. export function getImageMime(path: string): string | null { const mimes: Record = { 'apng': 'image/apng', 'png': 'image/png', 'svg': 'image/svg+xml', 'gif': 'image/gif', 'jpeg': 'image/jpeg', 'jpg': 'image/jpeg', 'webp': 'image/webp', }; const ext = path.split(".").pop(); if (ext) { if (mimes[ext]) { return mimes[ext]; } } return null; } // TODO: Needs testing with absolute and relative paths. export function canonicalize(path: string, base: string): string { const finalPath = base .split("/") .slice(0, -1) // Remove file name. .concat([path]) // Add image file path. .join("/"); // URL is used to resolve relative paths, eg. `../../assets/image.png`. const url = new URL(finalPath, document.location.origin); const pathname = url.pathname.replace(/^\//, ""); return pathname; } // Takes a URL, eg. "https://twitter.com/cloudhead", and return "cloudhead". // Returns the original string if it was unable to extract the username. export function parseUsername(input: string): string { const parts = input.split("/"); return parts[parts.length - 1]; } // Return the current unix time. export function unixTime(): number { return Math.floor(Date.now() / 1000); } // Check whether the input is a Radicle ID. export function isRadicleId(input: string): boolean { return /^rad:[a-z]+:[a-zA-Z0-9]+$/.test(input); } // Check whether the input is a SHA1 commit. export function isOid(input: string): boolean { return /^[a-fA-F0-9]{40}$/.test(input); } // Check whether the input is a URL. export function isUrl(input: string): boolean { return /^https?:\/\//.test(input); } // Check whether the input is a DID export function isDid(input: string): boolean { return /^did:[a-zA-Z0-9]+:[a-zA-Z0-9]+$/.test(input); } export function isENSName(input: string, config: Config): boolean { const domain = config.registrar.domain.replace(".", "\\."); const regEx = new RegExp(`^[a-zA-Z0-9]+.${domain}$`); return regEx.test(input); } // Check whether the input is an checksummed or all lowercase Ethereum address. export function isAddress(input: string): boolean { return ethers.utils.isAddress(input); } // Get search parameters from location. export function getSearchParam(key: string, location: RouteLocation): string | null { const params = new URLSearchParams(location.search); return params.get(key); } // Get the explorer link of an address, eg. Etherscan. export function explorerLink(addr: string, config: Config): string { if (config.network.name == "rinkeby") { return `https://rinkeby.etherscan.io/address/${addr}`; } return `https://etherscan.io/address/${addr}`; } // Get the Gnosis Safe link of an address, eg. Etherscan. export function safeLink(addr: string, config: Config): string { if (config.safe.viewer) { return `${config.safe.viewer}/${addr}`; } return explorerLink(addr, config); } // Query a subgraph. export async function querySubgraph( url: string, query: string, variables: Record, retries = 3 ): Promise { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ query, variables, }) }); const json = await response.json(); if (json.errors) { console.error("querySubgraph:", json.errors); if (retries > 0) querySubgraph(url, query, variables, retries - 1); else return null; } return json.data; } // Format a name. export function formatName(input: string, config: Config): string { return parseEnsLabel(input, config); } // Create a Radicle ID from a root hash. export function formatRadicleId(hash: Uint8Array): string { // Remove any zero-padding from the byte array. SHA1 is 20 bytes long. const sha1Bytes = 20; const suffix = hash.slice(hash.length - sha1Bytes); // Create a multihash by adding prefix 17 for SHA-1 and 20 for the hash length. const multihash = new Uint8Array([17, 20, ...suffix]); const payload = multibase.encode("base32z", multihash); return `rad:git:${new TextDecoder().decode(payload)}`; } // Parse a Radicle Id (URN). export function parseRadicleId(urn: string): string { return urn.replace(/^rad:[a-z]+:/, ""); } // Decode a Radicle Id (URN). export function decodeRadicleId(urn: string): Uint8Array { const encoded = parseRadicleId(urn); const multihash = multibase.decode(encoded); const hash = multihashes.decode(multihash); return hash.digest; } // Create a project hash from a hash and format. export function formatProjectHash(multihash: Uint8Array): string { const decoded = multihashes.decode(multihash); return ethers.utils.hexlify(decoded.digest).replace(/^0x/, ''); } // Identify an address by checking whether it's a contract or an externally-owned address. export async function identifyAddress(address: string, config: Config): Promise { const safe = await isSafe(address, config); if (safe) { return AddressType.Safe; } const code = await config.provider.getCode(address); const bytes = ethers.utils.arrayify(code); if (bytes.length > 0) { if (ethers.utils.keccak256(bytes) === config.orgs.contractHash) { return AddressType.Org; } return AddressType.Contract; } return AddressType.EOA; } // Resolve a label under the radicle domain. export async function resolveLabel(label: string | undefined, config: Config): Promise { if (label) return config.provider.resolveName(label); return null; } // Resolves an IDX profile or return null export async function resolveIdxProfile(caip10: string, config: Config): Promise { return config.ceramic.client.get("basicProfile", caip10); } // Resolves an ENS profile or return null export async function resolveEnsProfile(addressOrName: string, profileType: ProfileType, config: Config): Promise { const name = ethers.utils.isAddress(addressOrName) ? await config.provider.lookupAddress(addressOrName) : addressOrName; if (name) { const resolver = await config.provider.getResolver(name); if (! resolver) { return null; } if (profileType === ProfileType.Full) { const registration = await getRegistration(name, config, resolver); if (registration) { return registration.profile; } } else { const promises: [Promise] = [ getAvatar(name, config, resolver), ]; if (addressOrName === name) { promises.push(resolver.getAddress()); } else { promises.push(Promise.resolve(addressOrName)); } if (profileType === ProfileType.Project) { promises.push(getSeed(name, config, resolver)); promises.push(getAnchorsAccount(name, config, resolver)); } else if (profileType === ProfileType.Minimal) { promises.push(Promise.resolve(null)); } const project = await Promise.allSettled(promises); const [avatar, address, seed, anchorsAccount] = // Just checking for r.value equal null and casting to undefined, // since resolver functions return null. project.map(r => r.status == "fulfilled" && r.value ? r.value : null); return { name, avatar, address, seed, anchorsAccount, }; } } return null; } // Check whether a Gnosis Safe exists at an address. export async function isSafe(address: string, config: Config): Promise { // For the subgraph we need to pass a lowercase address const query = await querySubgraph(config.orgs.subgraph, GetSafe, { addr: address.toLowerCase() }); return query.safe !== null ? true : false; } // Get a Gnosis Safe at an address. export async function getSafe(address: string, config: Config): Promise { // For the subgraph we need to pass a lowercase address const query = await querySubgraph(config.orgs.subgraph, GetSafe, { addr: address.toLowerCase() }); if (! query?.safe) { return null; } return { address: query.safe.id, owners: query.safe.owners, threshold: query.safe.threshold }; } // Get token balances for an address. export async function getTokens(address: string, config: Config): Promise> { const userBalances = await config.provider.send("alchemy_getTokenBalances", [address, "DEFAULT_TOKENS"]); const balances = userBalances.tokenBalances.filter((token: any) => { // alchemy_getTokenBalances sometimes returns 0x and this does not work well with ethers.BigNumber if (token.tokenBalance !== "0x") { if (! BigNumber.from(token.tokenBalance).isZero()) { return token; } } }).map(async (token: any) => { const tokenMetaData = await config.provider.send("alchemy_getTokenMetadata", [token.contractAddress]); return { ...tokenMetaData, balance: BigNumber.from(token.tokenBalance) }; }); return Promise.all(balances); } // Check whether the given path has a markdown file extension. export function isMarkdownPath(path: string): boolean { return /\.(md|mkd|markdown)$/i.test(path); } // Check whether the given input string is a domain, eg. `alt-clients.radicle.xyz. // Also accepts in dev env 0.0.0.0 as domain export function isDomain(input: string): boolean { return (/^[a-z][a-z0-9.-]+$/.test(input) && /\.[a-z]+$/.test(input)) || (! import.meta.env.PROD && /^0.0.0.0$/.test(input)); } // Get the gravatar URL of an email. export function gravatarURL(email: string): string { const address = email.trim().toLowerCase(); const hash = md5(address); return `https://www.gravatar.com/avatar/${hash}`; } // Propose a Gnosis Safe multi-sig transaction. export async function proposeSafeTransaction( safeTx: SafeTransaction, safeAddress: string, config: Config ): Promise { assert(config.signer); assert(config.safe.client); const ethAdapter = new EthersAdapter({ ethers, signer: config.signer, }); const safeSdk = await EthersSafe.create({ ethAdapter, safeAddress, }); const estimation = await config.safe.client.estimateSafeTransaction( safeAddress, safeTx ); const transaction = await safeSdk.createTransaction({ ...safeTx, safeTxGas: Number(estimation.safeTxGas), }); const safeTxHash = await safeSdk.getTransactionHash(transaction); const signature = await safeSdk.signTransactionHash(safeTxHash); await config.safe.client.proposeTransaction( safeAddress, transaction.data, safeTxHash, signature ); } // Sign a Gnosis Safe multi-sig transaction. export async function signSafeTransaction( safeAddress: string, safeTxHash: string, config: Config ): Promise { assert(config.signer); const ethAdapter = new EthersAdapter({ ethers, signer: config.signer }); const safeSdk = await EthersSafe.create({ ethAdapter, safeAddress }); return await safeSdk.signTransactionHash(safeTxHash); } // Execute a Gnosis Safe signed transaction by safeTxHash. export async function executeSignedSafeTransaction( safeAddress: string, safeTxHash: string, config: Config ): Promise { assert(config.signer); assert(config.safe.client); const ethAdapter = new EthersAdapter({ ethers, signer: config.signer }); const safeSdk = await EthersSafe.create({ ethAdapter, safeAddress }); const signedTx = await config.safe.client.getTransaction(safeTxHash); assert(signedTx.data); assert(signedTx.confirmations); const safeTx = await safeSdk.createTransaction({ ...signedTx, gasPrice: Number(signedTx.gasPrice), data: signedTx.data }); signedTx.confirmations.forEach(confirmation => { const signature = new EthSignSignature(confirmation.owner, confirmation.signature); safeTx.addSignature(signature); }); return await safeSdk.executeTransaction(safeTx); } export class EthSignSignature { signer: string; data: string; constructor(signer: string, signature: string) { this.signer = signer; this.data = signature; } staticPart(): string { return this.data; } dynamicPart(): string { return ''; } }