diff --git a/.env.example b/.env.example index 6f2e78e..2db39be 100644 --- a/.env.example +++ b/.env.example @@ -14,8 +14,8 @@ NEXT_PUBLIC_WEB3_URL_PREFIX=https://eth-sepolia.g.alchemy.com/v2/ NEXT_PUBLIC_ALCHEMY_API_KEY="ALCHEMY KEY" NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID="YOUR WALLET CONNECT PROJECT ID" -NEXT_PUBLIC_IPFS_ENDPOINT="https://..." -NEXT_PUBLIC_IPFS_API_KEY="..." +NEXT_PUBLIC_IPFS_ENDPOINTS="https://domain1/api,https://domain2/api/v0" +NEXT_PUBLIC_PINATA_JWT="..." NEXT_PUBLIC_ETHERSCAN_API_KEY="OPTIONAL: ETHERSCAN API" # PRIVATE (scripts) @@ -24,5 +24,5 @@ DEPLOYMENT_WALLET_PRIVATE_KEY="0x..." DEPLOYMENT_ALCHEMY_API_KEY="..." DEPLOYMENT_WEB3_ENDPOINT="https://..." -DEPLOYMENT_IPFS_ENDPOINT="https://..." -DEPLOYMENT_IPFS_API_KEY="..." +DEPLOYMENT_APP_NAME="Taiko" +DEPLOYMENT_PINATA_JWT="..." diff --git a/.github/workflows/app-build.yml b/.github/workflows/app-build.yml index 3eebb8e..3e2d7c6 100644 --- a/.github/workflows/app-build.yml +++ b/.github/workflows/app-build.yml @@ -25,7 +25,7 @@ jobs: NEXT_PUBLIC_WEB3_URL_PREFIX: https://rpc/ NEXT_PUBLIC_ALCHEMY_API_KEY: x NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: x - NEXT_PUBLIC_IPFS_ENDPOINT: https://ipfs/ - NEXT_PUBLIC_IPFS_API_KEY: x + NEXT_PUBLIC_IPFS_ENDPOINTS: https://domain1/api,https://domain2/api/v0 + NEXT_PUBLIC_PINATA_JWT: x NEXT_PUBLIC_ETHERSCAN_API_KEY: x NODE_ENV: production diff --git a/bun.lockb b/bun.lockb index 186ae6e..aea284e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/constants.ts b/constants.ts index ed4f032..3183c55 100644 --- a/constants.ts +++ b/constants.ts @@ -28,8 +28,8 @@ export const PUB_ETHERSCAN_API_KEY = process.env.NEXT_PUBLIC_ETHERSCAN_API_KEY ? export const PUB_WALLET_CONNECT_PROJECT_ID = process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID ?? ""; -export const PUB_IPFS_ENDPOINT = process.env.NEXT_PUBLIC_IPFS_ENDPOINT ?? ""; -export const PUB_IPFS_API_KEY = process.env.NEXT_PUBLIC_IPFS_API_KEY ?? ""; +export const PUB_IPFS_ENDPOINTS = process.env.NEXT_PUBLIC_IPFS_ENDPOINTS ?? ""; +export const PUB_PINATA_JWT = process.env.NEXT_PUBLIC_PINATA_JWT ?? ""; // General export const PUB_APP_NAME = "Aragonette"; diff --git a/hooks/useMetadata.ts b/hooks/useMetadata.ts index 3f06da9..0d3cc14 100644 --- a/hooks/useMetadata.ts +++ b/hooks/useMetadata.ts @@ -1,4 +1,4 @@ -import { fetchJsonFromIpfs } from "@/utils/ipfs"; +import { fetchIpfsAsJson } from "@/utils/ipfs"; import { JsonValue } from "@/utils/types"; import { useQuery } from "@tanstack/react-query"; @@ -8,7 +8,7 @@ export function useMetadata(ipfsUri?: string) { queryFn: () => { if (!ipfsUri) return Promise.resolve(""); - return fetchJsonFromIpfs(ipfsUri); + return fetchIpfsAsJson(ipfsUri); }, retry: true, refetchOnMount: false, diff --git a/package.json b/package.json index 8fac7dc..6f50a65 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "classnames": "^2.5.1", "dayjs": "^1.11.10", "dompurify": "^3.0.11", - "ipfs-http-client": "^60.0.1", + "multiformats": "^13.1.3", "next": "14.1.4", "react": "^18.2.0", "react-blockies": "^1.4.1", diff --git a/plugins/dualGovernance/pages/new.tsx b/plugins/dualGovernance/pages/new.tsx index e62f57c..5d18d71 100644 --- a/plugins/dualGovernance/pages/new.tsx +++ b/plugins/dualGovernance/pages/new.tsx @@ -1,7 +1,6 @@ -import { create } from "ipfs-http-client"; import { Button, IconType, Icon, InputText, TextAreaRichText } from "@aragon/ods"; import React, { useEffect, useState } from "react"; -import { uploadToIPFS } from "@/utils/ipfs"; +import { uploadToPinata } from "@/utils/ipfs"; import { useWaitForTransactionReceipt, useWriteContract } from "wagmi"; import { toHex } from "viem"; import { OptimisticTokenVotingPluginAbi } from "@/plugins/dualGovernance/artifacts/OptimisticTokenVotingPlugin.sol"; @@ -13,7 +12,7 @@ import { getPlainText } from "@/utils/html"; import { useRouter } from "next/router"; import { Else, ElseIf, If, Then } from "@/components/if"; import { PleaseWaitSpinner } from "@/components/please-wait"; -import { PUB_IPFS_ENDPOINT, PUB_IPFS_API_KEY, PUB_DUAL_GOVERNANCE_PLUGIN_ADDRESS, PUB_CHAIN } from "@/constants"; +import { PUB_CHAIN, PUB_DUAL_GOVERNANCE_PLUGIN_ADDRESS } from "@/constants"; import { ActionCard } from "@/components/actions/action"; enum ActionType { @@ -22,11 +21,6 @@ enum ActionType { Custom, } -const ipfsClient = create({ - url: PUB_IPFS_ENDPOINT, - headers: { "X-API-KEY": PUB_IPFS_API_KEY, Accept: "application/json" }, -}); - export default function Create() { const { push } = useRouter(); const [title, setTitle] = useState(""); @@ -85,7 +79,7 @@ export default function Create() { }); const plainSummary = getPlainText(summary).trim(); - if (!plainSummary.trim()) + if (!plainSummary) return addAlert("Invalid proposal details", { description: "Please, enter a summary of what the proposal is about", type: "error", @@ -113,17 +107,14 @@ export default function Create() { } const proposalMetadataJsonObject = { title, summary }; - const blob = new Blob([JSON.stringify(proposalMetadataJsonObject)], { - type: "application/json", - }); + const ipfsUri = await uploadToPinata(JSON.stringify(proposalMetadataJsonObject)); - const ipfsPin = await uploadToIPFS(ipfsClient, blob); createProposalWrite({ chainId: PUB_CHAIN.id, abi: OptimisticTokenVotingPluginAbi, address: PUB_DUAL_GOVERNANCE_PLUGIN_ADDRESS, functionName: "createProposal", - args: [toHex(ipfsPin), actions, BigInt(0), BigInt(0), BigInt(0)], + args: [toHex(ipfsUri), actions, BigInt(0), BigInt(0), BigInt(0)], }); }; diff --git a/plugins/lockToVote/pages/new.tsx b/plugins/lockToVote/pages/new.tsx index bdfcdf5..fff8521 100644 --- a/plugins/lockToVote/pages/new.tsx +++ b/plugins/lockToVote/pages/new.tsx @@ -1,7 +1,6 @@ -import { create } from "ipfs-http-client"; import { Button, IconType, Icon, InputText, TextAreaRichText } from "@aragon/ods"; import React, { useEffect, useState } from "react"; -import { uploadToIPFS } from "@/utils/ipfs"; +import { uploadToPinata } from "@/utils/ipfs"; import { useWaitForTransactionReceipt, useWriteContract } from "wagmi"; import { toHex } from "viem"; import { OptimisticTokenVotingPluginAbi } from "@/plugins/dualGovernance/artifacts/OptimisticTokenVotingPlugin.sol"; @@ -13,7 +12,7 @@ import { getPlainText } from "@/utils/html"; import { useRouter } from "next/router"; import { Else, ElseIf, If, Then } from "@/components/if"; import { PleaseWaitSpinner } from "@/components/please-wait"; -import { PUB_IPFS_ENDPOINT, PUB_IPFS_API_KEY, PUB_CHAIN, PUB_LOCK_TO_VOTE_PLUGIN_ADDRESS } from "@/constants"; +import { PUB_CHAIN, PUB_LOCK_TO_VOTE_PLUGIN_ADDRESS } from "@/constants"; import { ActionCard } from "@/components/actions/action"; enum ActionType { @@ -22,11 +21,6 @@ enum ActionType { Custom, } -const ipfsClient = create({ - url: PUB_IPFS_ENDPOINT, - headers: { "X-API-KEY": PUB_IPFS_API_KEY, Accept: "application/json" }, -}); - export default function Create() { const { push } = useRouter(); const [title, setTitle] = useState(""); @@ -85,7 +79,7 @@ export default function Create() { }); const plainSummary = getPlainText(summary).trim(); - if (!plainSummary.trim()) + if (!plainSummary) return addAlert("Invalid proposal details", { description: "Please, enter a summary of what the proposal is about", type: "error", @@ -113,17 +107,14 @@ export default function Create() { } const proposalMetadataJsonObject = { title, summary }; - const blob = new Blob([JSON.stringify(proposalMetadataJsonObject)], { - type: "application/json", - }); + const ipfsUri = await uploadToPinata(JSON.stringify(proposalMetadataJsonObject)); - const ipfsPin = await uploadToIPFS(ipfsClient, blob); createProposalWrite({ chainId: PUB_CHAIN.id, abi: OptimisticTokenVotingPluginAbi, address: PUB_LOCK_TO_VOTE_PLUGIN_ADDRESS, functionName: "createProposal", - args: [toHex(ipfsPin), actions, BigInt(0), BigInt(0), BigInt(0)], + args: [toHex(ipfsUri), actions, BigInt(0), BigInt(0), BigInt(0)], }); }; diff --git a/plugins/tokenVoting/pages/new.tsx b/plugins/tokenVoting/pages/new.tsx index cbf1c66..25fac96 100644 --- a/plugins/tokenVoting/pages/new.tsx +++ b/plugins/tokenVoting/pages/new.tsx @@ -1,7 +1,6 @@ -import { create } from "ipfs-http-client"; import { Button, IconType, Icon, InputText, TextAreaRichText } from "@aragon/ods"; import React, { useEffect, useState } from "react"; -import { uploadToIPFS } from "@/utils/ipfs"; +import { uploadToPinata } from "@/utils/ipfs"; import { useWaitForTransactionReceipt, useWriteContract } from "wagmi"; import { toHex } from "viem"; import { TokenVotingAbi } from "@/plugins/tokenVoting/artifacts/TokenVoting.sol"; @@ -13,7 +12,7 @@ import { getPlainText } from "@/utils/html"; import { useRouter } from "next/router"; import { Else, ElseIf, If, Then } from "@/components/if"; import { PleaseWaitSpinner } from "@/components/please-wait"; -import { PUB_CHAIN, PUB_IPFS_API_KEY, PUB_IPFS_ENDPOINT, PUB_TOKEN_VOTING_PLUGIN_ADDRESS } from "@/constants"; +import { PUB_CHAIN, PUB_TOKEN_VOTING_PLUGIN_ADDRESS } from "@/constants"; import { ActionCard } from "@/components/actions/action"; enum ActionType { @@ -22,11 +21,6 @@ enum ActionType { Custom, } -const ipfsClient = create({ - url: PUB_IPFS_ENDPOINT, - headers: { "X-API-KEY": PUB_IPFS_API_KEY, Accept: "application/json" }, -}); - export default function Create() { const { push } = useRouter(); const [title, setTitle] = useState(""); @@ -86,7 +80,7 @@ export default function Create() { }); const plainSummary = getPlainText(summary).trim(); - if (!plainSummary.trim()) + if (!plainSummary) return addAlert("Invalid proposal details", { description: "Please, enter a summary of what the proposal is about", type: "error", @@ -114,17 +108,14 @@ export default function Create() { } const proposalMetadataJsonObject = { title, summary }; - const blob = new Blob([JSON.stringify(proposalMetadataJsonObject)], { - type: "application/json", - }); + const ipfsUri = await uploadToPinata(JSON.stringify(proposalMetadataJsonObject)); - const ipfsPin = await uploadToIPFS(ipfsClient, blob); createProposalWrite({ chainId: PUB_CHAIN.id, abi: TokenVotingAbi, address: PUB_TOKEN_VOTING_PLUGIN_ADDRESS, functionName: "createProposal", - args: [toHex(ipfsPin), actions, BigInt(0), BigInt(0), BigInt(0), 0, false], + args: [toHex(ipfsUri), actions, BigInt(0), BigInt(0), BigInt(0), 0, false], }); }; diff --git a/scripts/deploy.ts b/scripts/deploy.ts index b660c8a..30c499c 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -59,8 +59,8 @@ NEXT_PUBLIC_WEB3_URL_PREFIX=https://eth-${DEPLOYMENT_TARGET_CHAIN_ID}.g.alchemy. NEXT_PUBLIC_ALCHEMY_API_KEY= NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID= -NEXT_PUBLIC_IPFS_ENDPOINT= -NEXT_PUBLIC_IPFS_API_KEY= +NEXT_PUBLIC_IPFS_ENDPOINTS= +NEXT_PUBLIC_PINATA_JWT= NEXT_PUBLIC_ETHERSCAN_API_KEY= `; console.log(summary); diff --git a/scripts/deploy/5-dao.ts b/scripts/deploy/5-dao.ts index a9494ca..ca590bd 100644 --- a/scripts/deploy/5-dao.ts +++ b/scripts/deploy/5-dao.ts @@ -10,8 +10,7 @@ import { import { type Address, type Hex, type Log, decodeEventLog, toHex } from "viem"; import { deploymentPublicClient as publicClient, deploymentWalletClient as walletClient } from "../lib/util/client"; import { deploymentAccount as account } from "../lib/util/account"; -import { uploadToIPFS } from "@/utils/ipfs"; -import { deploymentIpfsClient as ipfsClient } from "../lib/util/ipfs"; +import { uploadToPinata } from "@/utils/ipfs"; import { ABI as DaoFactoryABI } from "../lib/artifacts/dao-factory"; import { ABI as DaoRegistryABI } from "../lib/artifacts/dao-registry"; import { ABI as PluginSetupProcessorABI } from "../lib/artifacts/plugin-setup-processor"; @@ -73,11 +72,8 @@ function pinDaoMetadata(): Promise { // }, // ], }; - const blob = new Blob([JSON.stringify(daoMetadata)], { - type: "application/json", - }); - return uploadToIPFS(ipfsClient, blob) + return uploadToPinata(JSON.stringify(daoMetadata)) .then((res) => toHex(res)) .catch((err) => { console.warn("Warning: Could not pin the DAO metadata on IPFS"); diff --git a/scripts/lib/util/ipfs.ts b/scripts/lib/util/ipfs.ts index 04e3def..7bcb35c 100644 --- a/scripts/lib/util/ipfs.ts +++ b/scripts/lib/util/ipfs.ts @@ -1,10 +1,29 @@ -import { create } from "ipfs-http-client"; import { getEnv } from "./env"; -const IPFS_ENDPOINT = getEnv("DEPLOYMENT_IPFS_ENDPOINT", true) ?? ""; -const IPFS_API_KEY = getEnv("DEPLOYMENT_IPFS_API_KEY", true) || ""; +const PINATA_JWT = getEnv("DEPLOYMENT_PINATA_JWT", true) || ""; +const APP_NAME = getEnv("DEPLOYMENT_APP_NAME", true) || ""; -export const deploymentIpfsClient = create({ - url: IPFS_ENDPOINT, - headers: { "X-API-KEY": IPFS_API_KEY, Accept: "application/json" }, -}); +const UPLOAD_FILE_NAME = APP_NAME.toLowerCase().trim().replaceAll(" ", "-") + ".json"; + +export async function uploadToPinata(strBody: string) { + const blob = new Blob([strBody], { type: "text/plain" }); + const file = new File([blob], UPLOAD_FILE_NAME); + const data = new FormData(); + data.append("file", file); + data.append("pinataMetadata", JSON.stringify({ name: UPLOAD_FILE_NAME })); + data.append("pinataOptions", JSON.stringify({ cidVersion: 1 })); + + const res = await fetch("https://api.pinata.cloud/pinning/pinFileToIPFS", { + method: "POST", + headers: { + Authorization: `Bearer ${PINATA_JWT}`, + }, + body: data, + }); + + const resData = await res.json(); + + if (resData.error) throw new Error("Request failed: " + resData.error); + else if (!resData.IpfsHash) throw new Error("Could not pin the metadata"); + return "ipfs://" + resData.IpfsHash; +} diff --git a/utils/ipfs.ts b/utils/ipfs.ts index d778dce..f7bfade 100644 --- a/utils/ipfs.ts +++ b/utils/ipfs.ts @@ -1,18 +1,57 @@ -import { PUB_IPFS_ENDPOINT, PUB_IPFS_API_KEY } from "@/constants"; -import { CID, IPFSHTTPClient } from "ipfs-http-client"; -import { Hex, fromHex } from "viem"; +import { PUB_IPFS_ENDPOINTS, PUB_PINATA_JWT, PUB_APP_NAME } from "@/constants"; +import { Hex, fromHex, toBytes } from "viem"; +import { CID } from "multiformats/cid"; +import * as raw from "multiformats/codecs/raw"; +import { sha256 } from "multiformats/hashes/sha2"; -export function fetchJsonFromIpfs(ipfsUri: string) { - return fetchFromIPFS(ipfsUri).then((res) => res.json()); +const IPFS_FETCH_TIMEOUT = 1000; // 1 second +const UPLOAD_FILE_NAME = PUB_APP_NAME.toLowerCase().trim().replaceAll(" ", "-") + ".json"; + +export function fetchIpfsAsJson(ipfsUri: string) { + return fetchRawIpfs(ipfsUri).then((res) => res.json()); +} + +export function fetchIpfsAsText(ipfsUri: string) { + return fetchRawIpfs(ipfsUri).then((res) => res.text()); } -export function uploadToIPFS(client: IPFSHTTPClient, blob: Blob) { - return client.add(blob).then(({ cid }: { cid: CID }) => { - return "ipfs://" + cid.toString(); +export function fetchIpfsAsBlob(ipfsUri: string) { + return fetchRawIpfs(ipfsUri).then((res) => res.blob()); +} + +export async function uploadToPinata(strBody: string) { + const blob = new Blob([strBody], { type: "text/plain" }); + const file = new File([blob], UPLOAD_FILE_NAME); + const data = new FormData(); + data.append("file", file); + data.append("pinataMetadata", JSON.stringify({ name: UPLOAD_FILE_NAME })); + data.append("pinataOptions", JSON.stringify({ cidVersion: 1 })); + + const res = await fetch("https://api.pinata.cloud/pinning/pinFileToIPFS", { + method: "POST", + headers: { + Authorization: `Bearer ${PUB_PINATA_JWT}`, + }, + body: data, }); + + const resData = await res.json(); + + if (resData.error) throw new Error("Request failed: " + resData.error); + else if (!resData.IpfsHash) throw new Error("Could not pin the metadata"); + return "ipfs://" + resData.IpfsHash; } -async function fetchFromIPFS(ipfsUri: string): Promise { +export async function getContentCid(strMetadata: string) { + const bytes = raw.encode(toBytes(strMetadata)); + const hash = await sha256.digest(bytes); + const cid = CID.create(1, raw.code, hash); + return "ipfs://" + cid.toV1().toString(); +} + +// Internal helpers + +async function fetchRawIpfs(ipfsUri: string): Promise { if (!ipfsUri) throw new Error("Invalid IPFS URI"); else if (ipfsUri.startsWith("0x")) { // fallback @@ -21,22 +60,25 @@ async function fetchFromIPFS(ipfsUri: string): Promise { if (!ipfsUri) throw new Error("Invalid IPFS URI"); } - const path = resolvePath(ipfsUri); - const controller = new AbortController(); - const id = setTimeout(() => controller.abort(), 800); - const response = await fetch(`${PUB_IPFS_ENDPOINT}/cat?arg=${path}`, { - method: "POST", - headers: { - "X-API-KEY": PUB_IPFS_API_KEY, - Accept: "application/json", - }, - signal: controller.signal, - }); - clearTimeout(id); - if (!response.ok) { - throw new Error("Could not connect to the IPFS endpoint"); + const uriPrefixes = PUB_IPFS_ENDPOINTS.split(",").filter((uri) => !!uri.trim()); + if (!uriPrefixes.length) throw new Error("No available IPFS endpoints to fetch from"); + + const cid = resolvePath(ipfsUri); + + for (const uriPrefix of uriPrefixes) { + const controller = new AbortController(); + const abortId = setTimeout(() => controller.abort(), IPFS_FETCH_TIMEOUT); + const response = await fetch(`${uriPrefix}/${cid}`, { + method: "GET", + signal: controller.signal, + }); + clearTimeout(abortId); + if (!response.ok) continue; + + return response; // .json(), .text(), .blob(), etc. } - return response; // .json(), .text(), .blob(), etc. + + throw new Error("Could not connect to any of the IPFS endpoints"); } function resolvePath(uri: string) {