From b661ce7b2fd58e7ff01faf47cf8fb3bb232841ec Mon Sep 17 00:00:00 2001 From: kien-ngo Date: Mon, 26 Aug 2024 22:45:35 +0000 Subject: [PATCH] [SDK] Deploy VoteERC20 extension (#4264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem solved Short description of the bug fixed or feature added --- ## PR-Codex overview This PR adds an extension for deploying the `VoteERC20` contract and includes related test cases and contract initialization improvements. ### Detailed summary - Added extension for deploying `VoteERC20` contract - Implemented test cases for contract deployment and functionality testing - Enhanced contract initialization process with better error handling > The following files were skipped due to too many changes: `packages/thirdweb/src/extensions/prebuilts/deploy-vote.ts`, `packages/thirdweb/src/extensions/prebuilts/__generated__/VoteERC20/write/initialize.ts` > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` --- .changeset/beige-deers-unite.md | 5 + .../generate/abis/prebuilts/VoteERC20.json | 3 + .../VoteERC20/write/initialize.ts | 230 ++++++++++++++++++ .../extensions/prebuilts/deploy-vote.test.ts | 73 ++++++ .../src/extensions/prebuilts/deploy-vote.ts | 213 ++++++++++++++++ .../vote/read/proposalExists.test.ts | 123 ++++++++++ 6 files changed, 647 insertions(+) create mode 100644 .changeset/beige-deers-unite.md create mode 100644 packages/thirdweb/scripts/generate/abis/prebuilts/VoteERC20.json create mode 100644 packages/thirdweb/src/extensions/prebuilts/__generated__/VoteERC20/write/initialize.ts create mode 100644 packages/thirdweb/src/extensions/prebuilts/deploy-vote.test.ts create mode 100644 packages/thirdweb/src/extensions/prebuilts/deploy-vote.ts create mode 100644 packages/thirdweb/src/extensions/vote/read/proposalExists.test.ts diff --git a/.changeset/beige-deers-unite.md b/.changeset/beige-deers-unite.md new file mode 100644 index 00000000000..bf31a573e37 --- /dev/null +++ b/.changeset/beige-deers-unite.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Add extension for deploying VoteERC20 contract diff --git a/packages/thirdweb/scripts/generate/abis/prebuilts/VoteERC20.json b/packages/thirdweb/scripts/generate/abis/prebuilts/VoteERC20.json new file mode 100644 index 00000000000..3c29d6df26c --- /dev/null +++ b/packages/thirdweb/scripts/generate/abis/prebuilts/VoteERC20.json @@ -0,0 +1,3 @@ +[ + "function initialize(string memory _name, string memory _contractURI, address[] memory _trustedForwarders, address _token, uint256 _initialVotingDelay, uint256 _initialVotingPeriod, uint256 _initialProposalThreshold, uint256 _initialVoteQuorumFraction)" +] \ No newline at end of file diff --git a/packages/thirdweb/src/extensions/prebuilts/__generated__/VoteERC20/write/initialize.ts b/packages/thirdweb/src/extensions/prebuilts/__generated__/VoteERC20/write/initialize.ts new file mode 100644 index 00000000000..b6d3d11d1e0 --- /dev/null +++ b/packages/thirdweb/src/extensions/prebuilts/__generated__/VoteERC20/write/initialize.ts @@ -0,0 +1,230 @@ +import type { AbiParameterToPrimitiveType } from "abitype"; +import type { + BaseTransactionOptions, + WithOverrides, +} from "../../../../../transaction/types.js"; +import { prepareContractCall } from "../../../../../transaction/prepare-contract-call.js"; +import { encodeAbiParameters } from "../../../../../utils/abi/encodeAbiParameters.js"; +import { once } from "../../../../../utils/promise/once.js"; +import type { ThirdwebContract } from "../../../../../contract/contract.js"; +import { detectMethod } from "../../../../../utils/bytecode/detectExtension.js"; + +/** + * Represents the parameters for the "initialize" function. + */ +export type InitializeParams = WithOverrides<{ + name: AbiParameterToPrimitiveType<{ type: "string"; name: "_name" }>; + contractURI: AbiParameterToPrimitiveType<{ + type: "string"; + name: "_contractURI"; + }>; + trustedForwarders: AbiParameterToPrimitiveType<{ + type: "address[]"; + name: "_trustedForwarders"; + }>; + token: AbiParameterToPrimitiveType<{ type: "address"; name: "_token" }>; + initialVotingDelay: AbiParameterToPrimitiveType<{ + type: "uint256"; + name: "_initialVotingDelay"; + }>; + initialVotingPeriod: AbiParameterToPrimitiveType<{ + type: "uint256"; + name: "_initialVotingPeriod"; + }>; + initialProposalThreshold: AbiParameterToPrimitiveType<{ + type: "uint256"; + name: "_initialProposalThreshold"; + }>; + initialVoteQuorumFraction: AbiParameterToPrimitiveType<{ + type: "uint256"; + name: "_initialVoteQuorumFraction"; + }>; +}>; + +export const FN_SELECTOR = "0x7cf43f8d" as const; +const FN_INPUTS = [ + { + type: "string", + name: "_name", + }, + { + type: "string", + name: "_contractURI", + }, + { + type: "address[]", + name: "_trustedForwarders", + }, + { + type: "address", + name: "_token", + }, + { + type: "uint256", + name: "_initialVotingDelay", + }, + { + type: "uint256", + name: "_initialVotingPeriod", + }, + { + type: "uint256", + name: "_initialProposalThreshold", + }, + { + type: "uint256", + name: "_initialVoteQuorumFraction", + }, +] as const; +const FN_OUTPUTS = [] as const; + +/** + * Checks if the `initialize` method is supported by the given contract. + * @param contract The ThirdwebContract. + * @returns A promise that resolves to a boolean indicating if the `initialize` method is supported. + * @extension PREBUILTS + * @example + * ```ts + * import { isInitializeSupported } from "thirdweb/extensions/prebuilts"; + * + * const supported = await isInitializeSupported(contract); + * ``` + */ +export async function isInitializeSupported(contract: ThirdwebContract) { + return detectMethod({ + contract, + method: [FN_SELECTOR, FN_INPUTS, FN_OUTPUTS] as const, + }); +} + +/** + * Encodes the parameters for the "initialize" function. + * @param options - The options for the initialize function. + * @returns The encoded ABI parameters. + * @extension PREBUILTS + * @example + * ```ts + * import { encodeInitializeParams } "thirdweb/extensions/prebuilts"; + * const result = encodeInitializeParams({ + * name: ..., + * contractURI: ..., + * trustedForwarders: ..., + * token: ..., + * initialVotingDelay: ..., + * initialVotingPeriod: ..., + * initialProposalThreshold: ..., + * initialVoteQuorumFraction: ..., + * }); + * ``` + */ +export function encodeInitializeParams(options: InitializeParams) { + return encodeAbiParameters(FN_INPUTS, [ + options.name, + options.contractURI, + options.trustedForwarders, + options.token, + options.initialVotingDelay, + options.initialVotingPeriod, + options.initialProposalThreshold, + options.initialVoteQuorumFraction, + ]); +} + +/** + * Encodes the "initialize" function into a Hex string with its parameters. + * @param options - The options for the initialize function. + * @returns The encoded hexadecimal string. + * @extension PREBUILTS + * @example + * ```ts + * import { encodeInitialize } "thirdweb/extensions/prebuilts"; + * const result = encodeInitialize({ + * name: ..., + * contractURI: ..., + * trustedForwarders: ..., + * token: ..., + * initialVotingDelay: ..., + * initialVotingPeriod: ..., + * initialProposalThreshold: ..., + * initialVoteQuorumFraction: ..., + * }); + * ``` + */ +export function encodeInitialize(options: InitializeParams) { + // we do a "manual" concat here to avoid the overhead of the "concatHex" function + // we can do this because we know the specific formats of the values + return (FN_SELECTOR + + encodeInitializeParams(options).slice( + 2, + )) as `${typeof FN_SELECTOR}${string}`; +} + +/** + * Prepares a transaction to call the "initialize" function on the contract. + * @param options - The options for the "initialize" function. + * @returns A prepared transaction object. + * @extension PREBUILTS + * @example + * ```ts + * import { initialize } from "thirdweb/extensions/prebuilts"; + * + * const transaction = initialize({ + * contract, + * name: ..., + * contractURI: ..., + * trustedForwarders: ..., + * token: ..., + * initialVotingDelay: ..., + * initialVotingPeriod: ..., + * initialProposalThreshold: ..., + * initialVoteQuorumFraction: ..., + * overrides: { + * ... + * } + * }); + * + * // Send the transaction + * ... + * + * ``` + */ +export function initialize( + options: BaseTransactionOptions< + | InitializeParams + | { + asyncParams: () => Promise; + } + >, +) { + const asyncOptions = once(async () => { + return "asyncParams" in options ? await options.asyncParams() : options; + }); + + return prepareContractCall({ + contract: options.contract, + method: [FN_SELECTOR, FN_INPUTS, FN_OUTPUTS] as const, + params: async () => { + const resolvedOptions = await asyncOptions(); + return [ + resolvedOptions.name, + resolvedOptions.contractURI, + resolvedOptions.trustedForwarders, + resolvedOptions.token, + resolvedOptions.initialVotingDelay, + resolvedOptions.initialVotingPeriod, + resolvedOptions.initialProposalThreshold, + resolvedOptions.initialVoteQuorumFraction, + ] as const; + }, + value: async () => (await asyncOptions()).overrides?.value, + accessList: async () => (await asyncOptions()).overrides?.accessList, + gas: async () => (await asyncOptions()).overrides?.gas, + gasPrice: async () => (await asyncOptions()).overrides?.gasPrice, + maxFeePerGas: async () => (await asyncOptions()).overrides?.maxFeePerGas, + maxPriorityFeePerGas: async () => + (await asyncOptions()).overrides?.maxPriorityFeePerGas, + nonce: async () => (await asyncOptions()).overrides?.nonce, + extraGas: async () => (await asyncOptions()).overrides?.extraGas, + erc20Value: async () => (await asyncOptions()).overrides?.erc20Value, + }); +} diff --git a/packages/thirdweb/src/extensions/prebuilts/deploy-vote.test.ts b/packages/thirdweb/src/extensions/prebuilts/deploy-vote.test.ts new file mode 100644 index 00000000000..24585513d3c --- /dev/null +++ b/packages/thirdweb/src/extensions/prebuilts/deploy-vote.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { ANVIL_CHAIN } from "~test/chains.js"; +import { TEST_CONTRACT_URI } from "~test/ipfs-uris.js"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import { TEST_ACCOUNT_A } from "~test/test-wallets.js"; +import { isAddress } from "../../utils/address.js"; +import { deployERC20Contract } from "./deploy-erc20.js"; +import { deployVoteContract } from "./deploy-vote.js"; + +describe.runIf(process.env.TW_SECRET_KEY)("deploy-voteERC20 contract", () => { + it("should deploy Vote contract", async () => { + const tokenAddress = await deployERC20Contract({ + client: TEST_CLIENT, + chain: ANVIL_CHAIN, + account: TEST_ACCOUNT_A, + type: "TokenERC20", + params: { + name: "Token", + contractURI: TEST_CONTRACT_URI, + }, + }); + const address = await deployVoteContract({ + account: TEST_ACCOUNT_A, + client: TEST_CLIENT, + chain: ANVIL_CHAIN, + params: { + name: "", + contractURI: TEST_CONTRACT_URI, + tokenAddress: tokenAddress, + // user needs 0.5 to create proposal + initialProposalThreshold: "0.5", + // vote expires 10 blocks later + initialVotingPeriod: 10, + // Requires 51% of users who voted, voted "For", for this proposal to pass + minVoteQuorumRequiredPercent: 51, + }, + }); + expect(address).toBeDefined(); + expect(isAddress(address)).toBe(true); + // Further tests to verify the functionality of this contract + // are done in other Vote tests + }); + + it("should throw if passed an non-integer-like value to minVoteQuorumRequiredPercent", async () => { + const tokenAddress = await deployERC20Contract({ + client: TEST_CLIENT, + chain: ANVIL_CHAIN, + account: TEST_ACCOUNT_A, + type: "TokenERC20", + params: { + name: "Token", + contractURI: TEST_CONTRACT_URI, + }, + }); + await expect(() => + deployVoteContract({ + account: TEST_ACCOUNT_A, + client: TEST_CLIENT, + chain: ANVIL_CHAIN, + params: { + name: "", + contractURI: TEST_CONTRACT_URI, + tokenAddress: tokenAddress, + initialProposalThreshold: "0.5", + initialVotingPeriod: 10, + minVoteQuorumRequiredPercent: 51.12, + }, + }), + ).rejects.toThrowError( + "51.12 is an invalid value. Only integer-like values accepted", + ); + }); +}); diff --git a/packages/thirdweb/src/extensions/prebuilts/deploy-vote.ts b/packages/thirdweb/src/extensions/prebuilts/deploy-vote.ts new file mode 100644 index 00000000000..6a22f853d4b --- /dev/null +++ b/packages/thirdweb/src/extensions/prebuilts/deploy-vote.ts @@ -0,0 +1,213 @@ +import type { Chain } from "../../chains/types.js"; +import type { ThirdwebClient } from "../../client/client.js"; +import { type ThirdwebContract, getContract } from "../../contract/contract.js"; +import { deployViaAutoFactory } from "../../contract/deployment/deploy-via-autofactory.js"; +import { getOrDeployInfraForPublishedContract } from "../../contract/deployment/utils/bootstrap.js"; +import type { FileOrBufferOrString } from "../../storage/upload/types.js"; +import type { Prettify } from "../../utils/type-utils.js"; +import type { ClientAndChainAndAccount } from "../../utils/types.js"; +import { decimals } from "../erc20/read/decimals.js"; + +/** + * @extension DEPLOY + */ +export type VoteContractParams = { + name: string; + /** + * The contract address for the ERC20 that will be used as voting power + */ + tokenAddress: string; + /** + * The number of blocks after a proposal is created that voting on the proposal starts. + * A block is a series of blockchain transactions and occurs every ~1 seconds. + * Block time is different across EVM networks + * + * Defaults to 0 (zero) + */ + initialVotingDelay?: number; + /** + * The number of blocks that voters have to vote on any new proposal. + */ + initialVotingPeriod: number; + /** + * The minimum number of voting tokens a wallet needs in order to create proposals. + * This amount that you have to enter is _not_ in wei. If you want users to have a least 0.5 ERC20 token to create proposals, + * enter `"0.5"` or `0.5`. The deploy script will fetch the ERC20 token's decimals and do the unit conversion for you. + */ + initialProposalThreshold: string | number; + /** + * The fraction of the total voting power that is required for a proposal to pass. + * A value of 0 indicates that no voting power is sufficient, + * whereas a value of 100 indicates that the entirety of voting power must vote for a proposal to pass. + * `initialProposalThreshold` should be an integer or an integer-convertoble string. For example: + * - 51 or "51" is a valid input + * - 51.225 or "51.225" is an invalid input + */ + minVoteQuorumRequiredPercent: number | string; + description?: string; + image?: FileOrBufferOrString; + external_link?: string; + social_urls?: Record; + symbol?: string; + contractURI?: string; + defaultAdmin?: string; + trustedForwarders?: string[]; +}; + +/** + * @extension DEPLOY + */ +export type DeployVoteContractOptions = Prettify< + ClientAndChainAndAccount & { + params: VoteContractParams; + } +>; + +/** + * Deploys a thirdweb [`VoteERC20 contract`](https://thirdweb.com/thirdweb.eth/VoteERC20) + * On chains where the thirdweb infrastructure contracts are not deployed, this function will deploy them as well. + * @param options - The deployment options. + * @returns The deployed contract address. + * @extension DEPLOY + * + * @example + * ```ts + * import { deployVoteContract } from "thirdweb/deploys"; + * const contractAddress = await deployVoteContract({ + * chain, + * client, + * account, + * params: { + * tokenAddress: "0x...", + * // user needs 0.5 to create proposal + * initialProposalThreshold: "0.5", + * // vote expires 10 blocks later + * initialVotingPeriod: 10, + * // Requires 51% of users who voted, voted "For", for this proposal to pass + * minVoteQuorumRequiredPercent: 51, + * } + * }); + * ``` + */ +export async function deployVoteContract(options: DeployVoteContractOptions) { + const { chain, client, account, params } = options; + const { cloneFactoryContract, implementationContract } = + await getOrDeployInfraForPublishedContract({ + chain, + client, + account, + contractId: "VoteERC20", + constructorParams: [], + }); + const initializeTransaction = await getInitializeTransaction({ + client, + implementationContract, + params, + accountAddress: account.address, + chain, + }); + + return deployViaAutoFactory({ + client, + chain, + account, + cloneFactoryContract, + initializeTransaction, + }); +} + +async function getInitializeTransaction(options: { + client: ThirdwebClient; + implementationContract: ThirdwebContract; + params: VoteContractParams; + accountAddress: string; + chain: Chain; +}) { + const { client, implementationContract, params, chain } = options; + const { + name, + tokenAddress, + initialProposalThreshold, + minVoteQuorumRequiredPercent, + initialVotingDelay, + initialVotingPeriod, + description, + symbol, + image, + external_link, + social_urls, + } = params; + const tokenErc20Contract = getContract({ + address: tokenAddress, + client, + chain, + }); + + /** + * A good side effect for checking for token decimals (instead of just taking in value in wei) + * is that it validates the token address that user entered. In case they enter an invalid ERC20 contract address, + * the extension will throw. + */ + const _decimals = await decimals({ contract: tokenErc20Contract }); + if (!_decimals) { + throw new Error(`Could not fetch decimals for contract: ${tokenAddress}`); + } + const [{ toUnits }, { upload }, { initialize }] = await Promise.all([ + import("../../utils/units.js"), + import("../../storage/upload.js"), + import("./__generated__/VoteERC20/write/initialize.js"), + ]); + const initialProposalThresholdInWei = toUnits( + String(initialProposalThreshold), + _decimals, + ); + const contractURI = + params.contractURI || + (await upload({ + client, + files: [ + { + name, + description, + symbol, + image, + external_link, + social_urls, + }, + ], + })) || + ""; + + // Validate initialVoteQuorumFraction + const _num = Number(minVoteQuorumRequiredPercent); + if (Number.isNaN(_num)) { + throw new Error( + `${minVoteQuorumRequiredPercent} is not a valid minVoteQuorumRequiredPercent`, + ); + } + if (_num < 0 || _num > 100) { + throw new Error("minVoteQuorumRequiredPercent must be >= 0 and <= 100"); + } + + // Make sure if user is passing a float, it should only have 2 digit after the decimal point + if (!Number.isInteger(_num)) { + throw new Error( + `${_num} is an invalid value. Only integer-like values accepted`, + ); + } + + const initialVoteQuorumFraction = BigInt(_num); + + return initialize({ + contract: implementationContract, + name, + token: tokenAddress, + // Make sure the final value passed to `initialProposalThreshold` is in wei + initialProposalThreshold: initialProposalThresholdInWei, + initialVoteQuorumFraction, + initialVotingDelay: BigInt(initialVotingDelay || 0), + initialVotingPeriod: BigInt(initialVotingPeriod), + contractURI, + trustedForwarders: params.trustedForwarders || [], + }); +} diff --git a/packages/thirdweb/src/extensions/vote/read/proposalExists.test.ts b/packages/thirdweb/src/extensions/vote/read/proposalExists.test.ts new file mode 100644 index 00000000000..22c536353a3 --- /dev/null +++ b/packages/thirdweb/src/extensions/vote/read/proposalExists.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { ANVIL_CHAIN } from "~test/chains.js"; +import { TEST_CONTRACT_URI } from "~test/ipfs-uris.js"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import { TEST_ACCOUNT_A } from "~test/test-wallets.js"; +import { getContract } from "../../../contract/contract.js"; +import { delegate } from "../../../extensions/erc20/__generated__/IVotes/write/delegate.js"; +import { mintTo } from "../../../extensions/erc20/write/mintTo.js"; +import { deployERC20Contract } from "../../../extensions/prebuilts/deploy-erc20.js"; +import { deployVoteContract } from "../../../extensions/prebuilts/deploy-vote.js"; +import { sendAndConfirmTransaction } from "../../../transaction/actions/send-and-confirm-transaction.js"; +import { propose } from "../__generated__/Vote/write/propose.js"; +import { getAll } from "./getAll.js"; +import { proposalExists } from "./proposalExists.js"; + +const account = TEST_ACCOUNT_A; +const client = TEST_CLIENT; +const chain = ANVIL_CHAIN; + +describe.runIf(process.env.TW_SECRET_KEY)("proposal exists", () => { + it("should return false if Vote doesn't have any proposal", async () => { + const tokenAddress = await deployERC20Contract({ + client: TEST_CLIENT, + chain: ANVIL_CHAIN, + account: TEST_ACCOUNT_A, + type: "TokenERC20", + params: { + name: "Token", + contractURI: TEST_CONTRACT_URI, + }, + }); + const address = await deployVoteContract({ + account: TEST_ACCOUNT_A, + client: TEST_CLIENT, + chain: ANVIL_CHAIN, + params: { + name: "", + contractURI: TEST_CONTRACT_URI, + tokenAddress: tokenAddress, + initialProposalThreshold: "0.5", + initialVotingPeriod: 10, + minVoteQuorumRequiredPercent: 51, + }, + }); + + const contract = getContract({ + address, + chain, + client, + }); + + const result = await proposalExists({ contract, proposalId: 0n }); + expect(result).toBe(false); + }); + + it("should return true if Vote has the proposal (id)", async () => { + const tokenAddress = await deployERC20Contract({ + client: TEST_CLIENT, + chain: ANVIL_CHAIN, + account: TEST_ACCOUNT_A, + type: "TokenERC20", + params: { + name: "Token", + contractURI: TEST_CONTRACT_URI, + }, + }); + const address = await deployVoteContract({ + account: TEST_ACCOUNT_A, + client: TEST_CLIENT, + chain: ANVIL_CHAIN, + params: { + name: "", + contractURI: TEST_CONTRACT_URI, + tokenAddress: tokenAddress, + initialProposalThreshold: "0.5", + initialVotingPeriod: 10, + minVoteQuorumRequiredPercent: 51, + }, + }); + + const contract = getContract({ + address, + chain, + client, + }); + + const tokenContract = getContract({ + address: tokenAddress, + chain, + client, + }); + // first step: mint enough tokens so it passes the voting threshold + const mintTransaction = mintTo({ + contract: tokenContract, + to: account.address, + amount: "1000", + }); + await sendAndConfirmTransaction({ transaction: mintTransaction, account }); + // 2nd step: to delegate the token + const delegation = delegate({ + contract: tokenContract, + delegatee: account.address, + }); + await sendAndConfirmTransaction({ transaction: delegation, account }); + + // step 3: create a proposal + const transaction = propose({ + contract, + description: "first proposal", + targets: [contract.address], + values: [0n], + calldatas: ["0x"], + }); + await sendAndConfirmTransaction({ transaction, account }); + const allProposals = await getAll({ contract }); + expect(allProposals.length).toBe(1); + const result = await proposalExists({ + contract, + proposalId: allProposals[0]?.proposalId || -1n, + }); + expect(result).toBe(true); + }); +});