diff --git a/README.md b/README.md index 05940ce..3acb888 100644 --- a/README.md +++ b/README.md @@ -48,42 +48,6 @@ pnpm deploy:sepolia Then you can add your DAO in [Tally](https://www.tally.xyz/) and/or spin up your own interface using [Gov UI](https://github.com/w3hc/gov-ui). -## Variants - -### Crosschain - -Make sure the main account has sufficient balance on OP Sepolia and Arbitrum Sepolia: - -``` -pnpm bal -``` - -Deploy: - -``` -pnpm deploy:all -``` - -Add a member (mint): - -``` -./scripts/mint.sh -``` - -Ban a member (burn): - -``` -./scripts/burn.sh -``` - -It will: - -- Deploy to OP Sepolia -- Deploy to Arbitrum Sepolia -- Submit a proposal and add a member -- Generate a membership proof on OP Sepolia -- Claim that proof on Arbitrum Sepolia - ## Security Here are the differences between the Governor/ERC-721 implementations suggested by Open Zeppelin and ours: @@ -126,6 +90,63 @@ The following functions are `onlyOwner`, and since the NFT contract ownership is | Base Sepolia | https://sepolia.basescan.org | https://api-sepolia.basescan.org/api | BASE_ETHERSCAN_API_KEY | | Arbitrum Sepolia | https://sepolia.arbiscan.io | https://api-sepolia.arbiscan.io/api | ARBITRUM_ETHERSCAN_API_KEY | +## Variants + +### Crosschain + +Make sure the main account has sufficient balance on OP Sepolia and Arbitrum Sepolia: + +``` +pnpm bal +``` + +Deploy: + +``` +pnpm deploy:all +``` + +It will: + +- Deploy to OP Sepolia +- Deploy to Arbitrum Sepolia + +Add a member (mint): + +``` +./scripts/mint.sh +``` + +It will: + +- Submit a proposal and add a member on OP Sepolia +- Generate a membership proof on OP Sepolia +- Claim that proof on Arbitrum Sepolia + +Ban a member (burn): + +``` +./scripts/burn.sh +``` + +It will: + +- Submit a proposal and ban a member on OP Sepolia +- Generate a burn proof on OP Sepolia +- Claim that proof on Arbitrum Sepolia + +Edit membership NFT metadata: + +``` +./scripts/metadata.sh +``` + +It will: + +- Submit a proposal edit the NFT metadata of tokenId 1 on OP Sepolia +- Generate a metadata proof on OP Sepolia +- Claim that proof on Arbitrum Sepolia + ## Core Dependencies - Node [v20.9.0](https://nodejs.org/uk/blog/release/v20.9.0/) diff --git a/package.json b/package.json index f70d8e3..c082fe8 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "compile": "hardhat compile", "test": "hardhat test", + "test:all": "./scripts/deploy.sh && ./scripts/mint.sh && ./scripts/burn.sh && ./scripts/metadata.sh", "test:crosschain": "hardhat test test/Gov-crosschain.ts", "deploy:optimism": "hardhat deploy --network optimism --reset", "deploy:base": "hardhat deploy --network base --reset", diff --git a/scripts/claim-metadata.ts b/scripts/claim-metadata.ts new file mode 100644 index 0000000..965548f --- /dev/null +++ b/scripts/claim-metadata.ts @@ -0,0 +1,100 @@ +import hre, { ethers } from "hardhat" +import { NFT__factory } from "../typechain-types/factories/contracts/variants/crosschain/NFT__factory" +import * as fs from "fs" +import * as path from "path" +import color from "cli-color" +var msg = color.xterm(39).bgXterm(128) + +function getDeployedAddress(network: string, contractName: string): string { + try { + const deploymentPath = path.join( + __dirname, + "..", + "deployments", + network, + `${contractName}.json` + ) + const deployment = JSON.parse(fs.readFileSync(deploymentPath, "utf8")) + return deployment.address + } catch (error) { + throw new Error( + `Failed to read deployment for ${contractName} on ${network}: ${error}` + ) + } +} + +function getProofFromData(): string { + try { + const dataPath = path.join(__dirname, "..", "data.json") + const data = JSON.parse(fs.readFileSync(dataPath, "utf8")) + return data.proof + } catch (error) { + throw new Error(`Failed to read proof from data.json: ${error}`) + } +} + +async function main() { + const SIGNER_PRIVATE_KEY = process.env.SIGNER_PRIVATE_KEY + if (!SIGNER_PRIVATE_KEY) { + throw new Error("Please set SIGNER_PRIVATE_KEY in your .env file") + } + + const networkName = hre.network.name + const NFT_ADDRESS = getDeployedAddress(networkName, "CrosschainNFT") + console.log("Using NFT contract address:", NFT_ADDRESS) + + const provider = new ethers.JsonRpcProvider( + networkName === "op-sepolia" + ? process.env.OP_SEPOLIA_RPC_ENDPOINT_URL + : process.env.ARBITRUM_SEPOLIA_RPC_ENDPOINT_URL + ) + const signerZero = new ethers.Wallet(SIGNER_PRIVATE_KEY, provider) + + console.log("Using address:", signerZero.address) + + const nft = NFT__factory.connect(NFT_ADDRESS, signerZero) + + const proof = getProofFromData() + console.log("\nUsing metadata proof:", proof) + + try { + console.log("Simulating metadata update claim...") + await nft.claimMetadataUpdate.staticCall(proof) + console.log("✅ Simulation successful") + + console.log("Submitting metadata update claim...") + const tx = await nft.claimMetadataUpdate(proof, { + gasLimit: 500000 + }) + + console.log("Transaction submitted:", msg(tx.hash)) + console.log("Waiting for confirmation...") + + const receipt = await tx.wait() + console.log("Metadata update claimed successfully!") + + const updateEvent = receipt?.logs.find(log => { + try { + return nft.interface.parseLog(log)?.name === "MetadataUpdated" + } catch { + return false + } + }) + + if (updateEvent) { + const parsedEvent = nft.interface.parseLog(updateEvent) + const tokenId = parsedEvent?.args?.tokenId + const newUri = parsedEvent?.args?.newUri + console.log("Updated token ID:", tokenId) + console.log("New metadata URI:", newUri) + } + } catch (error: any) { + console.error("\nError details:", error) + throw error + } +} + +main().catch(error => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scripts/metadata.sh b/scripts/metadata.sh new file mode 100755 index 0000000..acfa60c --- /dev/null +++ b/scripts/metadata.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# Color codes +GREEN='\033[0;32m' +RED='\033[0;31m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${BLUE}Starting cross-chain metadata update process...${NC}\n" + +# Create proposal on OP Sepolia +echo -e "\n${BLUE}Creating metadata update proposal on OP Sepolia...${NC}" +if npx hardhat run scripts/propose-metadata.ts --network op-sepolia; then + echo -e "${GREEN}✓ Metadata update proposal creation successful${NC}" +else + echo -e "${RED}✗ Metadata update proposal creation failed${NC}" + exit 1 +fi + +# Generate metadata proof from OP Sepolia +echo -e "\n${BLUE}Generating metadata proof from OP Sepolia...${NC}" +if npx hardhat run scripts/verify-metadata-proof.ts --network op-sepolia; then + echo -e "${GREEN}✓ Metadata proof generation successful${NC}" +else + echo -e "${RED}✗ Metadata proof generation failed${NC}" + exit 1 +fi + +# Claim metadata update on Arbitrum Sepolia +echo -e "\n${BLUE}Claiming metadata update on Arbitrum Sepolia...${NC}" +if npx hardhat run scripts/claim-metadata.ts --network arbitrum-sepolia; then + echo -e "${GREEN}✓ Metadata update claim successful${NC}" + echo -e "\n${GREEN}✓ All metadata update steps completed successfully!${NC}" + exit 0 +else + echo -e "${RED}✗ Metadata update claim failed${NC}" + exit 1 +fi \ No newline at end of file diff --git a/scripts/propose-metadata.ts b/scripts/propose-metadata.ts new file mode 100644 index 0000000..00c180f --- /dev/null +++ b/scripts/propose-metadata.ts @@ -0,0 +1,301 @@ +import hre, { ethers } from "hardhat" +import { Gov__factory } from "../typechain-types/factories/contracts/variants/crosschain/Gov__factory" +import { NFT__factory } from "../typechain-types/factories/contracts/variants/crosschain/NFT__factory" +import * as fs from "fs" +import * as path from "path" + +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +function getDeployedAddress(network: string, contractName: string): string { + try { + const deploymentPath = path.join( + __dirname, + "..", + "deployments", + network, + `${contractName}.json` + ) + const deployment = JSON.parse(fs.readFileSync(deploymentPath, "utf8")) + return deployment.address + } catch (error) { + throw new Error( + `Failed to read deployment for ${contractName} on ${network}: ${error}` + ) + } +} + +async function main() { + const ALICE_PRIVATE_KEY = process.env.ALICE + const SIGNER_PRIVATE_KEY = process.env.SIGNER_PRIVATE_KEY + if (!ALICE_PRIVATE_KEY) { + throw new Error("Please set ALICE private key in your .env file") + } + if (!SIGNER_PRIVATE_KEY) { + throw new Error("Please set SIGNER_PRIVATE_KEY in your .env file") + } + + // Get the network from hardhat config + const networkName = hre.network.name + + // Get deployed addresses from deployment files + const NFT_ADDRESS = getDeployedAddress(networkName, "CrosschainNFT") + const GOV_ADDRESS = getDeployedAddress(networkName, "CrosschainGov") + + console.log("Using contract addresses:") + console.log("NFT:", NFT_ADDRESS) + console.log("Gov:", GOV_ADDRESS) + + const provider = new ethers.JsonRpcProvider( + networkName === "op-sepolia" + ? process.env.OP_SEPOLIA_RPC_ENDPOINT_URL + : process.env.ARBITRUM_SEPOLIA_RPC_ENDPOINT_URL + ) + + const aliceSigner = new ethers.Wallet(ALICE_PRIVATE_KEY, provider) + const signerZero = new ethers.Wallet(SIGNER_PRIVATE_KEY, provider) + console.log("Using address for proposals:", aliceSigner.address) + console.log("Using address for execution:", signerZero.address) + + const gov = Gov__factory.connect(GOV_ADDRESS, aliceSigner) + const nft = NFT__factory.connect(NFT_ADDRESS, aliceSigner) + + const tokenId = 1n + const newUri = "ipfs://new-metadata-cid" + + // Check current voting power + const votingPower = await nft.getVotes(aliceSigner.address) + console.log("Current voting power:", votingPower) + + if (votingPower === 0n) { + console.log("Delegating voting power...") + const tx = await nft.delegate(aliceSigner.address) + await tx.wait(3) + console.log("Delegation completed") + console.log( + "New voting power:", + (await nft.getVotes(aliceSigner.address)).toString() + ) + } + + console.log("Creating proposal to update metadata...") + + try { + const targets = [nft.target] + const values = [0] + const calldatas = [ + nft.interface.encodeFunctionData("setMetadata", [tokenId, newUri]) + ] + const description = + "Update metadata for token " + tokenId + " " + Date.now() + + console.log("Creating proposal with:") + console.log("- Target:", targets[0]) + console.log("- Value:", values[0]) + console.log("- Calldata:", calldatas[0]) + console.log("- Description:", description) + + const tx = await gov + .connect(aliceSigner) + .propose(targets, values, calldatas, description) + + console.log("Proposal transaction submitted:", tx.hash) + let proposalId + const receipt = await tx.wait() + if (receipt) { + console.log("Proposal confirmed in block:", receipt.blockNumber) + proposalId = + receipt.logs[0] instanceof ethers.EventLog + ? receipt.logs[0].args?.[0] + : null + if (proposalId) { + console.log("Proposal ID:", proposalId) + } + } else { + throw new Error("Transaction failed - no receipt received") + } + + console.log("proposalId:", proposalId) + if (receipt) { + console.log("Proposal confirmed in block:", receipt.blockNumber) + const proposalIdFromEvent = + receipt.logs[0] instanceof ethers.EventLog + ? receipt.logs[0].args?.[0] + : null + console.log("Proposal ID from event:", proposalIdFromEvent) + + console.log("Checking proposal state before voting...") + const state = await gov.state(proposalId) + console.log( + "Current proposal state:", + getProposalState(Number(state)) + ) + + let currentState = Number(state) + let attempts = 0 + const maxAttempts = 10 + + while (currentState === 0 && attempts < maxAttempts) { + console.log("Waiting for proposal to become active...") + await sleep(30000) + + const newState = await gov.state(proposalId) + currentState = Number(newState) + console.log( + "Current proposal state:", + getProposalState(currentState) + ) + attempts++ + } + + if (proposalId) { + if (currentState === 1) { + console.log("Casting vote...") + const voteTx = await gov.castVote(proposalId, 1) + const voteReceipt = await voteTx.wait() + console.log("Vote cast successfully!") + + let isSucceeded = false + console.log("\nStarting to check proposal state...") + + while (!isSucceeded) { + const state = await gov.state(proposalId) + console.log( + "Current proposal state:", + getProposalState(Number(state)) + ) + + if (getProposalState(Number(state)) === "Succeeded") { + isSucceeded = true + console.log( + "\nProposal succeeded! Preparing for execution..." + ) + + try { + console.log("Execution parameters:") + console.log("- Targets:", targets) + console.log("- Values:", values) + console.log("- Calldatas:", calldatas) + console.log( + "- Description hash:", + ethers.id(description) + ) + + console.log( + "\nSubmitting execution transaction from Sepolia signer..." + ) + + // Connect with sepoliaSigner for execution + const executeTx = await gov + .connect(signerZero) + .execute( + targets, + values, + calldatas, + ethers.id(description) + ) + + console.log( + "Execution transaction submitted:", + executeTx.hash + ) + console.log("Waiting for confirmation...") + + const executeReceipt = await executeTx.wait() + console.log( + "Proposal executed successfully in block:", + executeReceipt?.blockNumber + ) + + try { + const newTokenUri = await nft.tokenURI( + tokenId + ) + console.log("New token URI:", newTokenUri) + } catch (error) { + console.log( + "Could not verify metadata update:", + error + ) + } + + break + } catch (error: any) { + console.error("\nError executing proposal:") + console.error("Error message:", error.message) + + if (error.data) { + try { + const decodedError = + gov.interface.parseError(error.data) + console.error( + "Decoded error:", + decodedError + ) + } catch (e) { + console.error( + "Raw error data:", + error.data + ) + } + } + + if (error.transaction) { + console.error("\nTransaction details:") + console.error("To:", error.transaction.to) + console.error( + "Data:", + error.transaction.data + ) + } + throw error + } + } + + console.log( + "Waiting 1 minute before next state check..." + ) + await sleep(60000) + } + } else { + console.log( + `Could not reach active state. Current state: ${getProposalState( + currentState + )}` + ) + } + } + } + } catch (error: any) { + console.error("\nError details:", error) + if (error.data) { + try { + const decodedError = gov.interface.parseError(error.data) + console.error("Decoded error:", decodedError) + } catch (e) { + console.error("Could not decode error data") + } + } + throw error + } +} + +function getProposalState(state: number): string { + const states = [ + "Pending", + "Active", + "Canceled", + "Defeated", + "Succeeded", + "Queued", + "Expired", + "Executed" + ] + return states[state] +} + +main().catch(error => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scripts/verify-metadata-proof.ts b/scripts/verify-metadata-proof.ts new file mode 100644 index 0000000..f543363 --- /dev/null +++ b/scripts/verify-metadata-proof.ts @@ -0,0 +1,54 @@ +import hre, { ethers } from "hardhat" +import { NFT__factory } from "../typechain-types/factories/contracts/variants/crosschain/NFT__factory" +import { NFT } from "../typechain-types/contracts/variants/crosschain/NFT" +import * as fs from "fs" +import * as path from "path" + +function getDeployedAddress(network: string, contractName: string): string { + try { + const deploymentPath = path.join( + __dirname, + "..", + "deployments", + network, + `${contractName}.json` + ) + const deployment = JSON.parse(fs.readFileSync(deploymentPath, "utf8")) + return deployment.address + } catch (error) { + throw new Error( + `Failed to read deployment for ${contractName} on ${network}: ${error}` + ) + } +} + +async function main() { + const networkName = hre.network.name + const NFT_ADDRESS = getDeployedAddress(networkName, "CrosschainNFT") + + console.log("Using NFT contract address:", NFT_ADDRESS) + + const NFTFactory = await ethers.getContractFactory( + "contracts/variants/crosschain/NFT.sol:NFT" + ) + const nft = NFT__factory.connect(NFT_ADDRESS, NFTFactory.runner) as NFT + + const tokenId = 1n + const newUri = "ipfs://new-metadata-cid" // Must be the same as the one used in propose-metadata.ts + + console.log("Generating metadata proof for token ID", tokenId) + const proof = await nft.generateMetadataProof(tokenId, newUri) + console.log("\nMetadata proof:", proof) + + const data = { proof: proof } + fs.writeFileSync( + path.join(__dirname, "..", "data.json"), + JSON.stringify(data, null, 2) + ) + console.log("\nMetadata proof written to data.json") +} + +main().catch(error => { + console.error(error) + process.exitCode = 1 +})