import { ethers } from "ethers"; import type { BigNumber } from "ethers"; import multibase from 'multibase'; import multihashes from 'multihashes'; import EthersSafe, { EthersAdapter } from "@gnosis.pm/safe-core-sdk"; 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, getSeedHost, getSeedId, getAnchorsAccount, getRegistration } from '@app/base/registrations/registrar'; import type { BasicProfile } from "@ceramicstudio/idx-constants"; import { ProfileType } from '@app/profile'; 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 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 isAddressEqual(left: string, right: string): boolean { return left.toLowerCase() === right.toLowerCase(); } export function formatSeedAddress(id: string, host: string, config: Config): string { return `${id}@${host}:${config.seed.link.port}`; } export function formatSeedId(id: string): string { return id.substring(0, 6) + '…' + id.substring(id.length - 6, id.length); } export function formatBalance(n: BigNumber): string { return ethers.utils.commify(parseFloat(ethers.utils.formatUnits(n)).toFixed(2)); } export function formatCAIP10Address(address: string, protocol: string, impl: number): string { return `${address.toLowerCase()}@${protocol}:${impl.toString()}`; } 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; } export function formatHash(hash: string): string { return hash.substring(0, 6) + '...' + hash.substring(hash.length - 4, hash.length); } export function formatNetwork(input: { name: string }): string { let name = input.name; if (name === "homestead") { name = "mainnet"; } return capitalize(name); } export function formatOrg(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.substr(1); } // Takes a domain name, eg. 'cloudhead.radicle.eth' and // returns the label, eg. 'cloudhead', otherwise `undefined`. export function parseEnsLabel(name: string, config: Config): string { const domain = config.registrar.domain.replace(".", "\\."); const label = name.replace(new RegExp(`\\.${domain}$`), ""); return label; } // 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 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 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) { for (const e of json.errors) { console.error("querySubgraph:", e.message); } 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}.${config.registrar.domain}`); return null; } // Resolves an IDX profile or return null export async function resolveIdxProfile(caip10: string, config: Config): Promise { return config.idx.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 = [ getAvatar(name, config, resolver), ]; if (addressOrName === name) { promises.push(resolver.getAddress()); } else { promises.push(Promise.resolve(addressOrName)); } if (profileType === ProfileType.Project) { promises.push(getSeedHost(name, config, resolver)); promises.push(getSeedId(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, seedHost, seedId, anchorsAccount] = project.map(r => r.status == "fulfilled" ? r.value : null); return { name, avatar, address, seedHost, seedId, anchorsAccount, url: null, twitter: null, github: null, }; } } return null; } // Check whether a Gnosis Safe exists at an address. export async function isSafe(address: string, config: Config): Promise { if (! config.safe.api) return false; const addr = ethers.utils.getAddress(address); const response = await fetch(`${config.safe.api}/api/v1/safes/${addr}/`, { method: 'HEAD' }); return response.ok; } // Get a Gnosis Safe at an address. export async function getSafe(address: string, config: Config): Promise { if (! config.safe.api) return null; const addr = ethers.utils.getAddress(address); const response = await fetch(`${config.safe.api}/api/v1/safes/${addr}/`, { method: 'GET', headers: { 'Accept': 'application/json', } }); if (! response.ok) { return null; } const json = await response.json(); return { address: json.address, owners: json.owners, threshold: json.threshold }; } // Get the Gnosis Safe addresses owned by the given address. export async function getOwnerSafes(owner: string, config: Config): Promise { if (! config.safe.api) return null; const addr = ethers.utils.getAddress(owner); const response = await fetch(`${config.safe.api}/api/v1/owners/${addr}/safes/`, { method: 'GET', headers: { 'Accept': 'application/json' } }); if (! response.ok) { return null; } const json = await response.json(); return json.safes; } // Get token balances for an address. export async function getTokens(address: string, config: Config): Promise> { await config.provider.send("alchemy_getTokenBalances", [address, config.tokens]); // TODO return []; } // Check whether the given path has a markdown file extension. export function isMarkdownPath(path: string): boolean { return /\.(md|mkd|markdown)$/i.test(path); } // 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 ); }