diff --git a/frontend/src/pages/CreatePollPage/index.tsx b/frontend/src/pages/CreatePollPage/index.tsx index ba05050..50317d0 100644 --- a/frontend/src/pages/CreatePollPage/index.tsx +++ b/frontend/src/pages/CreatePollPage/index.tsx @@ -14,7 +14,8 @@ export const CreatePollPage = () => { previousStep, nextStep, fields, hasErrorsOnCurrentPage, - createPoll + createPoll, + isCreating, } = useCreatePollForm() return ( @@ -28,7 +29,7 @@ export const CreatePollPage = () => {
{ stepIndex > 0 && } { stepIndex < numberOfSteps - 1 && } - { stepIndex === numberOfSteps - 1 && } + { stepIndex === numberOfSteps - 1 && }
diff --git a/frontend/src/pages/CreatePollPage/useCreatePollForm.ts b/frontend/src/pages/CreatePollPage/useCreatePollForm.ts index 2895cd5..12be660 100644 --- a/frontend/src/pages/CreatePollPage/useCreatePollForm.ts +++ b/frontend/src/pages/CreatePollPage/useCreatePollForm.ts @@ -11,10 +11,12 @@ import { import { useCreatePollUtils } from './useCreatePollUtils'; import classes from "./index.module.css" -import { StringUtils } from '../../utils/string.utils'; import { DateUtils } from '../../utils/date.utils'; import { useTime } from '../../hooks/useTime'; import { MIN_CLOSE_TIME_MINUTES } from '../../constants/config'; +import { AclOptions } from '../../types'; +import { renderAddress } from '../../components/Addresses'; +import { useNavigate } from 'react-router-dom'; // The steps / pages of the wizard const StepTitles= { @@ -59,6 +61,10 @@ export const useCreatePollForm = () => { checkXchainTokenHolder, getXchainTokenDetails, getXchainBlock, + getAllowAllACLOptions, + getTokenHolderAclOptions, + getAllowListAclOptions, + getXchainAclOptions, createPoll: doCreatePoll, } = useCreatePollUtils() @@ -67,6 +73,7 @@ export const useCreatePollForm = () => { const [stepIndex, setStepIndex] = useState(0); const [validationPending, setValidationPending] = useState(false) + const navigate = useNavigate() const { now } = useTime() const question= useTextField({ @@ -180,12 +187,15 @@ export const useCreatePollForm = () => { onItemEdited: (index, value, me) => { if ((value.indexOf(",") !== -1)|| (value.indexOf(" ") !== -1) || (value.indexOf("\n") !== -1)) { const addresses = splitAddresses(value) + const newAddresses = [...me.value] for (let i = 0; i < addresses.length; i++) { - me.value[index + i] = addresses[i] + newAddresses[index + i] = addresses[i] } - me.setValue(me.value) + me.setValue(newAddresses) } }, + validateOnChange: true, + showValidationSuccess: true, }) const chainChoices: Choice[] = useMemo( @@ -267,7 +277,8 @@ export const useCreatePollForm = () => { controls.updateStatus({message: progress}) }) if (!slot) return "Can't confirm this token at this wallet." - xchainWalletSlot.setValue(`Confirmed ${slot.balanceDecimal} ${xchainTokenSymbol.value} at slot #${slot.index}.`) + xchainWalletBalance.setValue(`Confirmed ${slot.balanceDecimal} ${xchainTokenSymbol.value}`) + xchainWalletSlotNumber.setValue(slot.index.toString()) }, async (_value, changed, controls) => { if (!changed) return @@ -275,7 +286,7 @@ export const useCreatePollForm = () => { const block = await getXchainBlock(chain.value) if (!block?.hash) return "Failed to fetch latest block." xchainBlockHash.setValue(block.hash) - xchainBlockHeight.setValue(block.number) + xchainBlockHeight.setValue(block.number.toString()) }, ], validateOnChange: true, @@ -284,14 +295,23 @@ export const useCreatePollForm = () => { const hasValidXchainWallet = hasValidXchainTokenAddress && xchainWalletAddress.isValidated && !xchainWalletAddress.hasProblems - const xchainWalletSlot = useLabel({ - name: "xchainWalletSlot", + const xchainWalletBalance = useLabel({ + name: "xchainWalletBalance", label: "Tokens confirmed:", visible: hasValidXchainWallet, initialValue: "", classnames: classes.explanation }) + const xchainWalletSlotNumber = useLabel({ + name: "xchainWalletSlotNumber", + label: "Stored at:", + visible: hasValidXchainWallet, + initialValue: "", + classnames: classes.explanation, + formatter: slot => `Slot #${slot}` + }) + const xchainBlockHash = useLabel({ name: "xchainBlockHash", label: "Reference Block Hash", @@ -349,17 +369,17 @@ export const useCreatePollForm = () => { ], } as const) - const suggestedAmountOfRose = useTextField({ + const amountOfSubsidy = useTextField({ name: "suggestedAmountOfRose", visible: gasFree.value, - label: "Suggested amount of ROSE", + label: "Amount of ROSE to set aside", }) useEffect( () => { if (!gasFree.value) return const cost = aclCostEstimates[accessControlMethod.value] * expectedRanges[numberOfExpectedVoters.value] - suggestedAmountOfRose.setValue(cost.toString()) + amountOfSubsidy.setValue(cost.toString()) }, [gasFree.value, accessControlMethod.value, numberOfExpectedVoters.value] ); @@ -389,7 +409,7 @@ export const useCreatePollForm = () => { description: "Everyone can see who voted for what.", }, ] - }) + } as const) const hasCloseDate = useBooleanField({ name: "hasCloseDate", @@ -434,12 +454,18 @@ export const useCreatePollForm = () => { if (hasValidCloseDate) { const deadline = pollCloseDate.value.getTime()/1000 const remaining = DateUtils.calculateRemainingTimeFrom(deadline, now) - pollCloseLabel.setValue(DateUtils.getTextDescriptionOfTime(remaining)) + pollCloseLabel.setValue(DateUtils.getTextDescriptionOfTime(remaining) ?? "") } }, [hasCloseDate.value, hasValidCloseDate, now], ); + const creationStatus = useLabel({ + name: "creationStatus", + label: "", + initialValue: "", + }) + const stepFields: Record = { basics: [ question, @@ -456,18 +482,19 @@ export const useCreatePollForm = () => { xchainTokenAddress, [xchainTokenName, xchainTokenSymbol], xchainWalletAddress, - xchainWalletSlot, + [xchainWalletBalance, xchainWalletSlotNumber], [xchainBlockHash, xchainBlockHeight], voteWeighting, gasFree, gasFreeExplanation, - [numberOfExpectedVoters, suggestedAmountOfRose], + [numberOfExpectedVoters, amountOfSubsidy], ], results: [ resultDisplayType, hasCloseDate, pollCloseDate, pollCloseLabel, + creationStatus, ], } @@ -487,24 +514,60 @@ export const useCreatePollForm = () => { setStepIndex(stepIndex + 1) } + const getAclOptions = async ( + updateStatus?: ((status: string | undefined) => void) | undefined, + ): Promise<[string, AclOptions]> => { + const acl = accessControlMethod.value + switch (acl) { + case 'acl_allowAll': + return getAllowAllACLOptions() + case 'acl_tokenHolder': + return getTokenHolderAclOptions(sapphireTokenAddress.value) + case 'acl_allowList': + return getAllowListAclOptions(addressWhitelist.value) + case 'acl_xchain': + return await getXchainAclOptions({ + chainName: chain.value, + contractAddress: xchainTokenAddress.value, + slotNumber: parseInt(xchainWalletSlotNumber.value), + blockHash: xchainBlockHash.value, + }, updateStatus) + default: + throw new Error(`Unknown ACL contract ${acl}`); + } + } + const createPoll = async () => { setValidationPending(true) const hasErrors = await findErrorsInFields(stepFields[step]) setValidationPending(false) if (hasErrors) return - console.log("Should create poll", question.value) + const logger = (message?: string | undefined) => creationStatus.setValue(message ?? "") + + // const logger = console.log setIsCreating(true) try { - const newId = await doCreatePoll( - question.value, - description.value, - answers.value, - ) + const [aclData, aclOptions] = await getAclOptions(logger) + const newId = await doCreatePoll({ + question: question.value, + description: description.value, + answers: answers.value, + aclData, aclOptions, + subsidizeAmount: gasFree.value ? 10n : undefined, + publishVotes: resultDisplayType.value === "percentages_and_votes", + closeTime: hasCloseDate.value ? pollCloseDate.value : undefined, + }, logger) + console.log("Created new poll", newId) + navigate(`/polls/${newId.substring(2)}`) } catch (ex) { - console.log("Failed to create poll", ex) + let exString = "" + ex; + if (exString.startsWith("Error: user rejected action")) { + exString = "Signer refused to sign transaction." + } + logger("Failed to create poll: " + exString) } finally { setIsCreating(false) } diff --git a/frontend/src/pages/CreatePollPage/useCreatePollUtils.ts b/frontend/src/pages/CreatePollPage/useCreatePollUtils.ts index edb97b9..f833c9c 100644 --- a/frontend/src/pages/CreatePollPage/useCreatePollUtils.ts +++ b/frontend/src/pages/CreatePollPage/useCreatePollUtils.ts @@ -1,6 +1,6 @@ import { useEthereum } from '../../hooks/useEthereum'; import { useContracts } from '../../hooks/useContracts'; -import { getAddress } from 'ethers'; +import { AbiCoder, getAddress, ParamType } from 'ethers'; import { AclOptions, Poll, PollManager } from "../../types" import { encryptJSON } from '../../utils/crypto.demo'; import { Pinata } from '../../utils/Pinata'; @@ -9,7 +9,9 @@ import { tokenDetailsFromProvider, xchainRPC, isERCTokenContract, - guessStorageSlot + guessStorageSlot, + getBlockHeaderRLP, + fetchAccountProof, } from '@oasisprotocol/side-dao-contracts'; import { useCallback } from 'react'; import { @@ -42,41 +44,135 @@ export const useCreatePollUtils = () => { , [] ) - const getACLOptions = async (): Promise<[string, AclOptions]> => { - const acl = acl_allowAll; - // const abi = AbiCoder.defaultAbiCoder(); + const getAllowAllACLOptions = (): [string, AclOptions] => { return [ '0x', // Empty bytes is passed { - address: acl, + address: VITE_CONTRACT_ACL_ALLOWALL, options: { allowAll: true }, }, ]; } + /** + * Encode the %%values%% as the %%types%% into ABI data. + * + * @returns DataHexstring + */ + const abiEncode = (types: ReadonlyArray, values: ReadonlyArray): string => { + const abi = AbiCoder.defaultAbiCoder(); + return abi.encode(types, values) + } + + const getTokenHolderAclOptions = (tokenAddress: string): [string, AclOptions] => { + return [ + abiEncode(['address'], [tokenAddress]), + { + address: VITE_CONTRACT_ACL_TOKENHOLDER, + options: { token: tokenAddress }, + }, + ]; + } + + const getAllowListAclOptions = (addresses: string[]): [string, AclOptions] => { + return [ + abiEncode(['address[]'], [addresses]), + { + address: VITE_CONTRACT_ACL_VOTERALLOWLIST, + options: { allowList: true }, + }, + ]; + } + + const getXchainAclOptions = async ( + props: { + chainName: string, + contractAddress: string, + slotNumber: number, + blockHash: string, + }, + updateStatus?: ((status: string | undefined) => void) | undefined, + ): Promise<[string, AclOptions]> => { + const showStatus = updateStatus ?? ((message?: string | undefined) => console.log(message)) + const { chainName, contractAddress, slotNumber, blockHash } = props + const chainId = chains[chainName] + const rpc = xchainRPC(chainId); + showStatus("Getting block header RLP") + const headerRlpBytes = await getBlockHeaderRLP(rpc, blockHash); + // console.log('headerRlpBytes', headerRlpBytes); + showStatus("Fetching account proof") + const rlpAccountProof = await fetchAccountProof(rpc, blockHash, contractAddress); + // console.log('rlpAccountProof', rlpAccountProof); + return [ + abiEncode( + ['tuple(tuple(bytes32,address,uint256),bytes,bytes)'], + [ + [ + [ + blockHash, + contractAddress, + slotNumber, + ], + headerRlpBytes, + rlpAccountProof, + ], + ], + ), + { + address: VITE_CONTRACT_ACL_STORAGEPROOF, + options: { + xchain: { + chainId, + blockHash, + address: contractAddress, + slot: slotNumber, + }, + }, + }, + ]; + } + const createPoll = async ( - question: string, - description: string, - answers: string[], + props: { + question: string, + description: string, + answers: string[], + aclData: string, + aclOptions: AclOptions, + subsidizeAmount: bigint | undefined, + publishVotes: boolean, + closeTime: Date | undefined, + }, + updateStatus: (message: string) => void, ) => { - const [aclData, aclOptions] = await getACLOptions(); + const { + question, description, answers, + aclData, aclOptions, + subsidizeAmount, + publishVotes, closeTime, + } = props + updateStatus("Compiling data") const poll: Poll = { creator: eth.state.address!, name: question, description, choices: answers, options: { - publishVotes: false, // publishVotes.value, - closeTimestamp: 0, //toValue(expirationTime) ? toValue(expirationTime)!.valueOf() / 1000 : 0, + publishVotes, + closeTimestamp: closeTime ? closeTime.getTime() / 1000 : 0, }, acl: aclOptions, }; const { key, cipherbytes } = encryptJSON(poll); + updateStatus("Saving poll data to IPFS") const ipfsHash = await Pinata.pinData(cipherbytes); - console.log('Poll ipfsHash', ipfsHash); + + if (!ipfsHash) throw new Error("Failed to save to IPFS, try again!") + // console.log('Poll ipfsHash', ipfsHash); + // updateStatus("Saved to IPFS") const proposalParams: PollManager.ProposalParamsStruct = { ipfsHash, @@ -84,14 +180,18 @@ export const useCreatePollUtils = () => { numChoices: answers.length, publishVotes: poll.options.publishVotes, closeTimestamp: poll.options.closeTimestamp, - acl: acl_allowAll, // toValue(chosenPollACL), + acl: aclOptions.address, }; + // console.log("params are", proposalParams) + + updateStatus("Calling signer") const createProposalTx = await daoSigner!.create(proposalParams, aclData, { - // Provide additional subsidy - value: 10, //toValue(subsidyAmount) ?? 0n, + value: subsidizeAmount ?? 0n, }); - console.log('doCreatePoll: creating proposal tx', createProposalTx.hash); + // console.log('doCreatePoll: creating proposal tx', createProposalTx.hash); + + updateStatus("Sending transaction") const receipt = (await createProposalTx.wait())!; if (receipt.status !== 1) { @@ -99,7 +199,11 @@ export const useCreatePollUtils = () => { } const proposalId = receipt.logs[0].data; - console.log('doCreatePoll: Proposal ID', proposalId); + updateStatus("Created poll") + + // console.log('doCreatePoll: Proposal ID', proposalId); + + return proposalId; } const getSapphireTokenDetails = async (address: string) => { @@ -148,6 +252,10 @@ export const useCreatePollUtils = () => { getXchainTokenDetails, checkXchainTokenHolder, getXchainBlock, + getAllowAllACLOptions, + getTokenHolderAclOptions, + getAllowListAclOptions, + getXchainAclOptions, createPoll, }