diff --git a/frontend/src/components/ACLs/allowAll.ts b/frontend/src/components/ACLs/allowAll.ts new file mode 100644 index 0000000..5d2fa75 --- /dev/null +++ b/frontend/src/components/ACLs/allowAll.ts @@ -0,0 +1,28 @@ +import { defineACL } from './common' +import { VITE_CONTRACT_ACL_ALLOWALL } from '../../constants/config' +import { denyWithReason } from '../InputFields' + +export const allowAll = defineACL({ + value: 'acl_allowAll', + label: 'Everybody', + costEstimation: 0.1, + useConfiguration: () => ({ + fields: [], + values: undefined, + }), + getAclOptions: () => [ + '0x', // Empty bytes is passed + { + address: VITE_CONTRACT_ACL_ALLOWALL, + options: { allowAll: true }, + }, + ], + isThisMine: options => 'allowAll' in options.options, + + checkPermission: async (pollACL, daoAddress, proposalId, userAddress) => { + const proof = new Uint8Array() + const result = 0n !== (await pollACL.canVoteOnPoll(daoAddress, proposalId, userAddress, proof)) + const canVote = result ? true : denyWithReason('some unknown reason') + return { canVote, proof } + }, +} as const) diff --git a/frontend/src/components/ACLs/allowList.ts b/frontend/src/components/ACLs/allowList.ts new file mode 100644 index 0000000..e849010 --- /dev/null +++ b/frontend/src/components/ACLs/allowList.ts @@ -0,0 +1,80 @@ +import { defineACL } from './common' +import { VITE_CONTRACT_ACL_VOTERALLOWLIST } from '../../constants/config' +import { abiEncode, isValidAddress } from '../../utils/poll.utils' +import { denyWithReason, useTextArrayField } from '../InputFields' +import { AclOptions } from '@oasisprotocol/blockvote-contracts' + +// Split a list of addresses by newLine, comma or space +const splitAddresses = (addressSoup: string): string[] => + addressSoup + .split('\n') + .flatMap(x => x.split(',')) + .flatMap(x => x.split(' ')) + .map(x => x.trim()) + .filter(x => x.length > 0) + +export const allowList = defineACL({ + value: 'acl_allowList', + label: 'Address Whitelist', + costEstimation: 0.1, + description: 'You can specify a list of addresses that are allowed to vote.', + useConfiguration: active => { + const addresses = useTextArrayField({ + name: 'addresses', + label: 'Acceptable Addresses', + visible: active, + description: 'You can just copy-paste your list here', + addItemLabel: 'Add address', + removeItemLabel: 'Remove address', + allowEmptyItems: [false, 'Please specify address, or remove this field!'], + minItems: 1, + allowDuplicates: [ + false, + ['This address is repeated below.', 'The same address was already listed above!'], + ], + itemValidator: value => + value && !isValidAddress(value) ? "This doesn't seem to be a valid address." : undefined, + 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++) { + newAddresses[index + i] = addresses[i] + } + me.setValue(newAddresses) + } + }, + validateOnChange: true, + showValidationSuccess: true, + }) + + return { + fields: [addresses], + values: { + addresses: addresses.value, + }, + } + }, + + getAclOptions: (props): [string, AclOptions] => { + if (!props.addresses) throw new Error('Internal errors: parameter mismatch, addresses missing.') + return [ + abiEncode(['address[]'], [props.addresses]), + { + address: VITE_CONTRACT_ACL_VOTERALLOWLIST, + options: { allowList: true }, + }, + ] + }, + + isThisMine: options => 'allowList' in options.options, + + checkPermission: async (pollACL, daoAddress, proposalId, userAddress) => { + const proof = new Uint8Array() + const explanation = 'This poll is only for a predefined list of addresses.' + const result = 0n !== (await pollACL.canVoteOnPoll(daoAddress, proposalId, userAddress, proof)) + // console.log("whiteListAcl check:", result) + const canVote = result ? true : denyWithReason('you are not on the list of allowed addresses') + return { canVote, explanation, proof } + }, +} as const) diff --git a/frontend/src/components/ACLs/common.ts b/frontend/src/components/ACLs/common.ts new file mode 100644 index 0000000..000ff68 --- /dev/null +++ b/frontend/src/components/ACLs/common.ts @@ -0,0 +1,56 @@ +import { Choice, DecisionWithReason, FieldConfiguration } from '../InputFields' +import { AclOptions, IPollACL } from '@oasisprotocol/blockvote-contracts' +import { BytesLike } from 'ethers' +export type StatusUpdater = (status: string | undefined) => void + +/** + * This data structure describes an ACL + */ +export type ACL = Choice & { + /** + * Estimated cost per vote + * + * This is used for setting up gasless voting. + */ + costEstimation: number + + /** + * Specify the fields and values needed for configuring the ACL when creating a poll + */ + useConfiguration: (selected: boolean) => { fields: FieldConfiguration; values: ConfigInputValues } + + /** + * Compose the ACL options when creating a poll + */ + getAclOptions: + | ((config: ConfigInputValues, statusUpdater?: StatusUpdater) => [string, Options]) + | ((config: ConfigInputValues, statusUpdater?: StatusUpdater) => Promise<[string, Options]>) + + /** + * Attempt to recognize if this ACL is managing a given poll, based on ACL options + * @param options + */ + isThisMine: (options: AclOptions) => boolean + + /** + * Determine if we can vote on this poll + * + * The actual contract is made available, this function just needs to interpret the result + * and compose the required messages. + */ + checkPermission: ( + pollACL: IPollACL, + daoAddress: string, + proposalId: string, + userAddress: string, + options: Options, + ) => Promise< + { canVote: DecisionWithReason; explanation?: string; proof: BytesLike; error?: string } & Extra + > +} + +export function defineACL( + acl: ACL, +): ACL { + return acl +} diff --git a/frontend/src/components/ACLs/index.module.css b/frontend/src/components/ACLs/index.module.css new file mode 100644 index 0000000..e5d77b6 --- /dev/null +++ b/frontend/src/components/ACLs/index.module.css @@ -0,0 +1,6 @@ +.explanation { + font-style: normal; + font-weight: 400; + font-size: 15px; + color: #414749; +} diff --git a/frontend/src/components/ACLs/index.ts b/frontend/src/components/ACLs/index.ts new file mode 100644 index 0000000..65317a1 --- /dev/null +++ b/frontend/src/components/ACLs/index.ts @@ -0,0 +1,15 @@ +import { allowAll } from './allowAll' +import { allowList } from './allowList' +import { tokenHolder } from './tokenHolder' +import { xchain } from './xchain' +import { AclOptions } from '../../types' + +/** + * The list of supported ACLs + */ +export const acls = [allowAll, allowList, tokenHolder, xchain] as const + +/** + * Find the ACL needed to manage a poll, based on ACL options + */ +export const findACLForOptions = (options: AclOptions) => acls.find(acl => acl.isThisMine(options)) diff --git a/frontend/src/components/ACLs/tokenHolder.ts b/frontend/src/components/ACLs/tokenHolder.ts new file mode 100644 index 0000000..c4a547b --- /dev/null +++ b/frontend/src/components/ACLs/tokenHolder.ts @@ -0,0 +1,90 @@ +import { defineACL } from './common' +import { DecisionWithReason, denyWithReason, useLabel, useTextField } from '../InputFields' +import { abiEncode, getSapphireTokenDetails, isValidAddress } from '../../utils/poll.utils' +import { VITE_CONTRACT_ACL_TOKENHOLDER } from '../../constants/config' + +export const tokenHolder = defineACL({ + value: 'acl_tokenHolder', + costEstimation: 0.2, + label: 'Holds Token on Sapphire', + hidden: true, // We decided to hide this option, since this is not the focus + useConfiguration: active => { + const tokenAddress = useTextField({ + name: 'tokenAddress', + label: 'Token Address', + visible: active, + required: [true, 'Please specify the address of the token that is the key to this poll!'], + validators: [ + value => (!isValidAddress(value) ? "This doesn't seem to be a valid address." : undefined), + async (value, controls) => { + controls.updateStatus({ message: 'Fetching token details...' }) + const details = await getSapphireTokenDetails(value) + if (!details) { + return "Can't find token details!" + } + tokenName.setValue(details.name) + tokenSymbol.setValue(details.symbol) + }, + ], + validateOnChange: true, + showValidationSuccess: true, + }) + + const hasValidSapphireTokenAddress = + tokenAddress.visible && tokenAddress.isValidated && !tokenAddress.hasProblems + + const tokenName = useLabel({ + name: 'tokenName', + visible: hasValidSapphireTokenAddress, + label: 'Selected token:', + initialValue: '', + }) + + const tokenSymbol = useLabel({ + name: 'tokenSymbol', + visible: hasValidSapphireTokenAddress, + label: 'Symbol:', + initialValue: '', + }) + + return { + fields: [tokenAddress, [tokenName, tokenSymbol]], + values: { + tokenAddress: tokenAddress.value, + }, + } + }, + + getAclOptions: props => { + if (!props.tokenAddress) throw new Error('Internal errors: parameter mismatch, addresses missing.') + return [ + abiEncode(['address'], [props.tokenAddress]), + { + address: VITE_CONTRACT_ACL_TOKENHOLDER, + options: { token: props.tokenAddress }, + }, + ] + }, + + isThisMine: options => 'token' in options.options, + + checkPermission: async (pollACL, daoAddress, proposalId, userAddress, options) => { + const tokenAddress = options.options.token + const tokenInfo = await getSapphireTokenDetails(tokenAddress) + const explanation = `You need to hold some ${tokenInfo?.name ?? 'specific'} token (on the Sapphire network) to vote.` + const proof = new Uint8Array() + let canVote: DecisionWithReason + try { + const result = 0n !== (await pollACL.canVoteOnPoll(daoAddress, proposalId, userAddress, proof)) + // console.log("tokenHolderAcl check:", result) + if (result) { + canVote = true + } else { + canVote = denyWithReason(`you don't hold any ${tokenInfo?.name} tokens`) + } + } catch { + canVote = denyWithReason(`you don't hold any ${tokenInfo?.name} tokens`) + } + return { canVote, explanation, proof } + }, +} as const) diff --git a/frontend/src/components/ACLs/xchain.ts b/frontend/src/components/ACLs/xchain.ts new file mode 100644 index 0000000..65f1367 --- /dev/null +++ b/frontend/src/components/ACLs/xchain.ts @@ -0,0 +1,315 @@ +import { defineACL } from './common' +import { + addMockValidation, + Choice, + DecisionWithReason, + denyWithReason, + useLabel, + useOneOfField, + useTextField, +} from '../InputFields' +import { + abiEncode, + chainsForXchain, + checkXchainTokenHolder, + ContractType, + getChainDefinition, + getContractDetails, + getLatestBlock, + isToken, + isValidAddress, +} from '../../utils/poll.utils' +import { renderAddress } from '../Addresses' +import { + AclOptionsXchain, + fetchAccountProof, + fetchStorageProof, + fetchStorageValue, + getBlockHeaderRLP, + xchainRPC, +} from '@oasisprotocol/blockvote-contracts' +import { VITE_CONTRACT_ACL_STORAGEPROOF } from '../../constants/config' +import classes from './index.module.css' +import { BytesLike, getUint } from 'ethers' +import { useMemo } from 'react' + +export const xchain = defineACL({ + value: 'acl_xchain', + label: 'Cross-Chain DAO', + costEstimation: 0.2, + description: 'You can set a condition that is evaluated on another chain.', + useConfiguration: active => { + const chainChoices: Choice[] = useMemo( + () => + chainsForXchain.map(([id, name]) => ({ + value: id, + label: `${name} (${id})`, + })), + [], + ) + + const chain = useOneOfField({ + name: 'chainId', + label: 'Chain', + visible: active, + choices: chainChoices, + onValueChange: (_, isStillFresh) => { + if (contractAddress.isValidated) { + void contractAddress.validate({ forceChange: true, reason: 'change', isStillFresh }) + } + }, + }) + + const contractAddress = useTextField({ + name: 'contractAddress', + label: 'Contract Address', + visible: active, + placeholder: 'Contract address on chain. (Token or NFT)', + required: [true, 'Please specify the address on the other chain that is the key to this poll!'], + validators: [ + value => (isValidAddress(value) ? undefined : "This doesn't seem to be a valid address."), + async (value, controls) => { + controls.updateStatus({ message: 'Checking out contract...' }) + const details = await getContractDetails(chain.value, value) + if (details) { + contractType.setValue(details.type) + contractName.setValue(details.name ?? details.addr) // TODO maybe shorten this + symbol.setValue(details.symbol ?? '(none)') + } else { + contractType.setValue('Unknown') + return 'Failed to load token details!' + } + }, + ], + validateOnChange: true, + showValidationSuccess: true, + onValueChange: (_, isStillFresh) => { + if (walletAddress.isValidated) { + void walletAddress.validate({ forceChange: true, reason: 'change', isStillFresh }) + } + }, + }) + + const hasValidTokenAddress = + contractAddress.visible && contractAddress.isValidated && !contractAddress.hasProblems + + const contractType = useLabel({ + name: 'contractType', + visible: hasValidTokenAddress, + label: 'Type:', + compact: true, + initialValue: 'Unknown', + validators: value => { + switch (value) { + case 'ERC-20': + return + case 'ERC-721': + return + // return { message: 'Some ERC-721 tokens are not supported', level: 'warning' } + case 'ERC-1155': + return 'Unfortunately, ERC-1155 NFTs are Not supported at the moment. Please use another token of NFT.' + case 'Unknown': + default: + return "We can't recognize this as a supported contract. Please use another token or NFT." + } + }, + validateOnChange: true, + showValidationSuccess: true, + }) + + const contractName = useLabel({ + name: 'contractName', + visible: hasValidTokenAddress && contractType.value !== 'Unknown', + label: `${isToken(contractType.value as any) ? 'Token' : 'NFT'}:`, + initialValue: '', + compact: true, + ...addMockValidation, + }) + + const symbol = useLabel({ + name: 'symbol', + visible: hasValidTokenAddress && contractType.value !== 'Unknown', + label: 'Symbol:', + compact: true, + initialValue: '', + ...addMockValidation, + }) + + const walletAddress = useTextField({ + name: 'walletAddress', + label: 'Wallet Address', + visible: hasValidTokenAddress && !contractType.hasProblems, + placeholder: 'Wallet address of a token holder on chain', + required: [true, 'Please specify the address of a token holder!'], + validators: [ + value => (isValidAddress(value) ? undefined : "This doesn't seem to be a valid address."), + async (value, controls) => { + if (contractType.value === 'Unknown') { + return "Can't check balance for contracts of unknown type!" + } + + const slot = await checkXchainTokenHolder( + chain.value, + contractAddress.value, + contractType.value, + value, + controls.isStillFresh, + progress => { + controls.updateStatus({ message: progress }) + }, + ) + if (!slot) { + if (contractType.value === 'ERC-721') { + return "Can't find this NFT in this wallet. Please note the not all ERC-721 NFTs are supported. This one might not be. If this is important, please open an issue." + } else { + return "Can't confirm this token at this wallet." + } + } + walletBalance.setValue(`Confirmed ${slot.balanceDecimal} ${symbol.value}`) + slotNumber.setValue(slot.index.toString()) + }, + async (_value, controls) => { + controls.updateStatus({ message: 'Looking up reference block ...' }) + const block = await getLatestBlock(chain.value) + if (!block?.hash) return 'Failed to fetch latest block.' + blockHash.setValue(block.hash) + blockHeight.setValue(block.number.toString()) + }, + ], + validateOnChange: true, + showValidationSuccess: true, + }) + + const hasValidWallet = hasValidTokenAddress && walletAddress.isValidated && !walletAddress.hasProblems + + const walletBalance = useLabel({ + name: 'walletBalance', + label: 'Balance:', + visible: hasValidWallet, + initialValue: '', + classnames: classes.explanation, + ...addMockValidation, + }) + + const slotNumber = useLabel({ + name: 'slotNumber', + label: 'Stored at:', + visible: hasValidWallet, + initialValue: '', + classnames: classes.explanation, + formatter: slot => `Slot #${slot}`, + ...addMockValidation, + }) + + const blockHash = useLabel({ + name: 'blockHash', + label: 'Reference Block Hash', + visible: hasValidWallet, + initialValue: 'unknown', + classnames: classes.explanation, + renderer: renderAddress, + ...addMockValidation, + }) + + const blockHeight = useLabel({ + name: 'blockHeight', + label: 'Block Height', + visible: hasValidWallet, + initialValue: 'unknown', + classnames: classes.explanation, + ...addMockValidation, + }) + + return { + fields: [ + chain, + contractAddress, + contractType, + [contractName, symbol], + walletAddress, + [walletBalance, slotNumber], + [blockHash, blockHeight], + ], + values: { + chainId: chain.value, + contractAddress: contractAddress.value, + slotNumber: slotNumber.value, + blockHash: blockHash.value, + }, + } + }, + getAclOptions: async ({ chainId, contractAddress, slotNumber, blockHash }, updateStatus) => { + const showStatus = updateStatus ?? ((message?: string | undefined) => console.log(message)) + 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); + + const options: AclOptionsXchain = { + xchain: { + chainId, + blockHash, + address: contractAddress, + slot: parseInt(slotNumber), + }, + } + + return [ + abiEncode( + ['tuple(tuple(bytes32,address,uint256),bytes,bytes)'], + [[[blockHash, contractAddress, slotNumber], headerRlpBytes, rlpAccountProof]], + ), + { + address: VITE_CONTRACT_ACL_STORAGEPROOF, + options, + }, + ] + }, + + isThisMine: options => 'xchain' in options.options, + + checkPermission: async (pollACL, daoAddress, proposalId, userAddress, options) => { + const xChainOptions = options.options + let explanation = '' + let error = '' + let proof: BytesLike = '' + let tokenInfo + let canVote: DecisionWithReason = true + const { + xchain: { chainId, blockHash, address: tokenAddress, slot }, + } = xChainOptions + const provider = xchainRPC(chainId) + const chainDefinition = getChainDefinition(chainId) + try { + tokenInfo = await getContractDetails(chainId, tokenAddress) + if (!tokenInfo) throw new Error("Can't load token details") + explanation = `This poll is only for those who have hold ${tokenInfo?.name} token on ${chainDefinition.name} when the poll was created.` + let isBalancePositive = false + const holderBalance = getUint( + await fetchStorageValue(provider, blockHash, tokenAddress, slot, userAddress), + ) + if (holderBalance > BigInt(0)) { + // Only attempt to get a proof if the balance is non-zero + proof = await fetchStorageProof(provider, blockHash, tokenAddress, slot, userAddress) + const result = await pollACL.canVoteOnPoll(daoAddress, proposalId, userAddress, proof) + if (0n !== result) { + isBalancePositive = true + canVote = true + } + } + if (!isBalancePositive) { + canVote = denyWithReason(`you don't hold any ${tokenInfo.name} tokens on ${chainDefinition.name}`) + } + } catch (e) { + const problem = e as any + error = problem.error?.message ?? problem.reason ?? problem.code ?? problem + console.error('Error when testing permission to vote on', proposalId, ':', error) + console.error('proof:', proof) + canVote = denyWithReason(`there was a technical problem verifying your permissions`) + } + return { canVote, explanation, error, proof, tokenInfo, xChainOptions } + }, +} as const) diff --git a/frontend/src/components/InputFields/useOneOfField.ts b/frontend/src/components/InputFields/useOneOfField.ts index 3a46c8c..3dd5d41 100644 --- a/frontend/src/components/InputFields/useOneOfField.ts +++ b/frontend/src/components/InputFields/useOneOfField.ts @@ -14,13 +14,13 @@ type OneOfFieldProps = Omit< 'initialValue' | 'required' | 'placeholder' > & { initialValue?: DataType - choices: Choice[] + readonly choices: readonly Choice[] requiredMessage?: string disableIfOnlyOneVisibleChoice?: boolean } export type OneOfFieldControls = InputFieldControls & { - choices: Choice[] + choices: readonly Choice[] } export function useOneOfField(props: OneOfFieldProps): OneOfFieldControls { diff --git a/frontend/src/components/InputFields/validation.ts b/frontend/src/components/InputFields/validation.ts index 7945249..f11202f 100644 --- a/frontend/src/components/InputFields/validation.ts +++ b/frontend/src/components/InputFields/validation.ts @@ -1,5 +1,6 @@ import { InputFieldControls, ValidationReason } from './useInputField' -import { getAsArray, SingleOrArray } from './util' +import { AsyncValidatorFunction, getAsArray, SingleOrArray } from './util' +import { LabelProps } from './useLabel' type FieldLike = Pick, 'name' | 'type' | 'visible' | 'validate' | 'hasProblems'> @@ -24,3 +25,17 @@ export const collectErrorsInFields = (fields: FieldConfiguration): boolean => .flatMap(config => getAsArray(config)) .filter(field => field.visible) .some(field => field.hasProblems) + +const sleep = (time: number) => new Promise(resolve => setTimeout(() => resolve(''), time)) + +const mockValidator: AsyncValidatorFunction = async (_value, controls) => { + if (!controls.isStillFresh()) return undefined + await sleep(500) + return undefined +} + +export const addMockValidation: Partial = { + showValidationSuccess: true, + validators: mockValidator, + validateOnChange: true, +} diff --git a/frontend/src/hooks/usePollPermissions.ts b/frontend/src/hooks/usePollPermissions.ts index 61fc2f2..b3784a2 100644 --- a/frontend/src/hooks/usePollPermissions.ts +++ b/frontend/src/hooks/usePollPermissions.ts @@ -76,7 +76,7 @@ export const usePollPermissions = (poll: ExtendedPoll | undefined, onDashboard: userAddress, proposalId, aclAddress: poll.proposal.params.acl, - options: poll.ipfsParams.acl.options, + options: poll.ipfsParams.acl, } const context: CheckPermissionContext = { diff --git a/frontend/src/pages/CreatePollPage/index.module.css b/frontend/src/pages/CreatePollPage/index.module.css index d08fdd4..5a96451 100644 --- a/frontend/src/pages/CreatePollPage/index.module.css +++ b/frontend/src/pages/CreatePollPage/index.module.css @@ -17,7 +17,3 @@ font-size: 15px; color: #414749; } - -.addressStatus { - width: 4em !important; -} diff --git a/frontend/src/pages/CreatePollPage/useCreatePollForm.ts b/frontend/src/pages/CreatePollPage/useCreatePollForm.ts index a68de0e..a65057d 100644 --- a/frontend/src/pages/CreatePollPage/useCreatePollForm.ts +++ b/frontend/src/pages/CreatePollPage/useCreatePollForm.ts @@ -1,13 +1,12 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useState } from 'react' import { - AsyncValidatorFunction, Choice, collectErrorsInFields, deny, FieldConfiguration, findErrorsInFields, + flatten, getVerdict, - LabelProps, useBooleanField, useDateField, useLabel, @@ -15,33 +14,16 @@ import { useTextArrayField, useTextField, } from '../../components/InputFields' -import { - chainsForXchain, - checkXchainTokenHolder, - createPoll as doCreatePoll, - getAllowAllACLOptions, - getAllowListAclOptions, - getSapphireTokenDetails, - getTokenHolderAclOptions, - getXchainAclOptions, - getLatestBlock, - isValidAddress, - parseEther, - CreatePollProps, - getContractDetails, - ContractType, - isToken, -} from '../../utils/poll.utils' +import { createPoll as doCreatePoll, parseEther, CreatePollProps } from '../../utils/poll.utils' import { useEthereum } from '../../hooks/useEthereum' import { useContracts } from '../../hooks/useContracts' - import classes from './index.module.css' import { DateUtils } from '../../utils/date.utils' import { useTime } from '../../hooks/useTime' import { designDecisions, MIN_COMPLETION_TIME_MINUTES } from '../../constants/config' -import { AclOptions } from '../../types' -import { renderAddress } from '../../components/Addresses' + import { useNavigate } from 'react-router-dom' +import { acls } from '../../components/ACLs' // The steps / pages of the wizard const StepTitles = { @@ -61,41 +43,11 @@ const expectedRanges = { '10000-': 100000, } as const -const aclCostEstimates = { - acl_allowAll: 0.1, - acl_allowList: 0.1, - acl_tokenHolder: 0.2, - acl_xchain: 0.2, -} as const - -// Split a list of addresses by newLine, comma or space -const splitAddresses = (addressSoup: string): string[] => - addressSoup - .split('\n') - .flatMap(x => x.split(',')) - .flatMap(x => x.split(' ')) - .map(x => x.trim()) - .filter(x => x.length > 0) - const hideDisabledIfNecessary = (choice: Choice): Choice => ({ ...choice, hidden: choice.hidden || (designDecisions.hideDisabledSelectOptions && !getVerdict(choice.enabled, true)), }) -const sleep = (time: number) => new Promise(resolve => setTimeout(() => resolve(''), time)) - -const mockValidator: AsyncValidatorFunction = async (_value, controls) => { - if (!controls.isStillFresh()) return undefined - await sleep(500) - return undefined -} - -const addMockValidation: Partial> = { - showValidationSuccess: true, - validators: mockValidator, - validateOnChange: true, -} - export const useCreatePollForm = () => { const eth = useEthereum() const { pollManagerWithSigner: daoSigner } = useContracts(eth) @@ -147,274 +99,19 @@ export const useCreatePollForm = () => { const accessControlMethod = useOneOfField({ name: 'accessControlMethod', label: 'Who can vote', - choices: [ - { value: 'acl_allowAll', label: 'Everybody' }, - { - value: 'acl_tokenHolder', - label: 'Holds Token on Sapphire', - hidden: true, // We decided to hide this option, since this is not the focus - }, - { - value: 'acl_allowList', - label: 'Address Whitelist', - description: 'You can specify a list of addresses that are allowed to vote.', - }, - { - value: 'acl_xchain', - label: 'Cross-Chain DAO', - description: 'You can set a condition that is evaluated on another chain.', - }, - ], + choices: acls, } as const) - const sapphireTokenAddress = useTextField({ - name: 'tokenAddress', - label: 'Token Address', - visible: accessControlMethod.value === 'acl_tokenHolder', - required: [true, 'Please specify the address of the token that is the key to this poll!'], - validators: [ - value => (!isValidAddress(value) ? "This doesn't seem to be a valid address." : undefined), - async (value, controls) => { - controls.updateStatus({ message: 'Fetching token details...' }) - const details = await getSapphireTokenDetails(value) - if (!details) { - return "Can't find token details!" - } - sapphireTokenName.setValue(details.name) - sapphireTokenSymbol.setValue(details.symbol) - }, - ], - validateOnChange: true, - showValidationSuccess: true, - }) - - const hasValidSapphireTokenAddress = - sapphireTokenAddress.visible && sapphireTokenAddress.isValidated && !sapphireTokenAddress.hasProblems + const aclConfig = acls.map(acl => ({ + name: acl.value, + ...acl.useConfiguration(acl.value === accessControlMethod.value), + })) - const sapphireTokenName = useLabel({ - name: 'sapphireTokenName', - visible: hasValidSapphireTokenAddress, - label: 'Selected token:', - initialValue: '', - }) + const currentAcl = acls.find(acl => acl.value === accessControlMethod.value)! - const sapphireTokenSymbol = useLabel({ - name: 'sapphireTokenSymbol', - visible: hasValidSapphireTokenAddress, - label: 'Symbol:', - initialValue: '', - }) + const currentAclConfig = aclConfig.find(a => a.name === accessControlMethod.value)! - const addressWhitelist = useTextArrayField({ - name: 'addressWhitelist', - label: 'Acceptable Addresses', - description: 'You can just copy-paste your list here', - addItemLabel: 'Add address', - removeItemLabel: 'Remove address', - visible: accessControlMethod.value === 'acl_allowList', - allowEmptyItems: [false, 'Please specify address, or remove this field!'], - minItems: 1, - allowDuplicates: [ - false, - ['This address is repeated below.', 'The same address was already listed above!'], - ], - itemValidator: value => - value && !isValidAddress(value) ? "This doesn't seem to be a valid address." : undefined, - 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++) { - newAddresses[index + i] = addresses[i] - } - me.setValue(newAddresses) - } - }, - validateOnChange: true, - showValidationSuccess: true, - }) - - const chainChoices: Choice[] = useMemo( - () => - chainsForXchain.map(([id, name]) => ({ - value: id, - label: `${name} (${id})`, - })), - [], - ) - - const chain = useOneOfField({ - name: 'chain', - label: 'Chain', - visible: accessControlMethod.value === 'acl_xchain', - choices: chainChoices, - onValueChange: (_, isStillFresh) => { - if (xchainContractAddress.isValidated) { - void xchainContractAddress.validate({ forceChange: true, reason: 'change', isStillFresh }) - } - }, - }) - - const xchainContractAddress = useTextField({ - name: 'xchainContractAddress', - label: 'Contract Address', - visible: accessControlMethod.value === 'acl_xchain', - placeholder: 'Contract address on chain. (Token or NFT)', - required: [true, 'Please specify the address on the other chain that is the key to this poll!'], - validators: [ - value => (isValidAddress(value) ? undefined : "This doesn't seem to be a valid address."), - async (value, controls) => { - controls.updateStatus({ message: 'Checking out contract...' }) - const details = await getContractDetails(chain.value, value) - if (details) { - xchainContractType.setValue(details.type) - xchainContractName.setValue(details.name) - xchainSymbol.setValue(details.symbol) - } else { - xchainContractType.setValue('Unknown') - return 'Failed to load token details!' - } - }, - ], - validateOnChange: true, - showValidationSuccess: true, - onValueChange: (_, isStillFresh) => { - if (xchainWalletAddress.isValidated) { - void xchainWalletAddress.validate({ forceChange: true, reason: 'change', isStillFresh }) - } - }, - }) - - const hasValidXchainTokenAddress = - xchainContractAddress.visible && xchainContractAddress.isValidated && !xchainContractAddress.hasProblems - - const xchainContractType = useLabel({ - name: 'xchainContractType', - visible: hasValidXchainTokenAddress, - label: 'Type:', - compact: true, - initialValue: 'Unknown', - validators: value => { - switch (value) { - case 'ERC-20': - return - case 'ERC-721': - return - // return { message: 'Some ERC-721 tokens are not supported', level: 'warning' } - case 'ERC-1155': - return 'Unfortunately, ERC-1155 NFTs are Not supported at the moment. Please use another token of NFT.' - case 'Unknown': - default: - return "We can't recognize this as a supported contract. Please use another token or NFT." - } - }, - validateOnChange: true, - showValidationSuccess: true, - }) - - const xchainContractName = useLabel({ - name: 'xchainContractName', - visible: hasValidXchainTokenAddress && xchainContractType.value !== 'Unknown', - label: `${isToken(xchainContractType.value as any) ? 'Token' : 'NFT'}:`, - initialValue: '', - compact: true, - ...addMockValidation, - }) - - const xchainSymbol = useLabel({ - name: 'xchainSymbol', - visible: hasValidXchainTokenAddress && xchainContractType.value !== 'Unknown', - label: 'Symbol:', - compact: true, - initialValue: '', - ...addMockValidation, - }) - - const xchainWalletAddress = useTextField({ - name: 'xchainWalletAddress', - label: 'Wallet Address', - visible: hasValidXchainTokenAddress && !xchainContractType.hasProblems, - placeholder: 'Wallet address of a token holder on chain', - required: [true, 'Please specify the address of a token holder!'], - validators: [ - value => (isValidAddress(value) ? undefined : "This doesn't seem to be a valid address."), - async (value, controls) => { - if (xchainContractType.value === 'Unknown') { - return "Can't check balance for contracts of unknown type!" - } - - const slot = await checkXchainTokenHolder( - chain.value, - xchainContractAddress.value, - xchainContractType.value, - value, - controls.isStillFresh, - progress => { - controls.updateStatus({ message: progress }) - }, - ) - if (!slot) { - if (xchainContractType.value === 'ERC-721') { - return "Can't find this NFT in this wallet. Please note the not all ERC-721 NFTs are supported. This one might not be. If this is important, please open an issue." - } else { - return "Can't confirm this token at this wallet." - } - } - xchainWalletBalance.setValue(`Confirmed ${slot.balanceDecimal} ${xchainSymbol.value}`) - xchainWalletSlotNumber.setValue(slot.index.toString()) - }, - async (_value, controls) => { - controls.updateStatus({ message: 'Looking up reference block ...' }) - const block = await getLatestBlock(chain.value) - if (!block?.hash) return 'Failed to fetch latest block.' - xchainBlockHash.setValue(block.hash) - xchainBlockHeight.setValue(block.number.toString()) - }, - ], - validateOnChange: true, - showValidationSuccess: true, - }) - - const hasValidXchainWallet = - hasValidXchainTokenAddress && xchainWalletAddress.isValidated && !xchainWalletAddress.hasProblems - - const xchainWalletBalance = useLabel({ - name: 'xchainWalletBalance', - label: 'Balance:', - visible: hasValidXchainWallet, - initialValue: '', - classnames: classes.explanation, - ...addMockValidation, - }) - - const xchainWalletSlotNumber = useLabel({ - name: 'xchainWalletSlotNumber', - label: 'Stored at:', - visible: hasValidXchainWallet, - initialValue: '', - classnames: classes.explanation, - formatter: slot => `Slot #${slot}`, - ...addMockValidation, - }) - - const xchainBlockHash = useLabel({ - name: 'xchainBlockHash', - label: 'Reference Block Hash', - visible: hasValidXchainWallet, - initialValue: 'unknown', - classnames: classes.explanation, - renderer: renderAddress, - ...addMockValidation, - }) - - const xchainBlockHeight = useLabel({ - name: 'xchainBlockHeight', - label: 'Block Height', - visible: hasValidXchainWallet, - initialValue: 'unknown', - classnames: classes.explanation, - ...addMockValidation, - }) + const allAclFieldsToShow = flatten(aclConfig.map(g => g.fields)) const voteWeighting = useOneOfField({ name: 'voteWeighting', @@ -466,9 +163,9 @@ export const useCreatePollForm = () => { useEffect(() => { if (!gasFree.value) return - const cost = aclCostEstimates[accessControlMethod.value] * expectedRanges[numberOfExpectedVoters.value] + const cost = currentAcl.costEstimation * expectedRanges[numberOfExpectedVoters.value] amountOfSubsidy.setValue(cost.toString()) - }, [gasFree.value, accessControlMethod.value, numberOfExpectedVoters.value]) + }, [gasFree.value, currentAcl, numberOfExpectedVoters.value]) const resultDisplayType = useOneOfField({ name: 'resultDisplayType', @@ -587,16 +284,7 @@ export const useCreatePollForm = () => { basics: [question, description, answers, customCSS], permission: [ accessControlMethod, - sapphireTokenAddress, - [sapphireTokenName, sapphireTokenSymbol], - addressWhitelist, - chain, - xchainContractAddress, - xchainContractType, - [xchainContractName, xchainSymbol], - xchainWalletAddress, - [xchainWalletBalance, xchainWalletSlotNumber], - [xchainBlockHash, xchainBlockHeight], + ...allAclFieldsToShow, voteWeighting, gasFree, gasFreeExplanation, @@ -628,32 +316,6 @@ 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( - { - chainId: chain.value, - contractAddress: xchainContractAddress.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], 'submit', () => true) @@ -665,12 +327,13 @@ export const useCreatePollForm = () => { const logger = (message?: string | undefined) => creationStatus.setValue(message ?? '') - // const logger = console.log - setIsCreating(true) try { - const [aclData, aclOptions] = await getAclOptions(logger) - + const aclConfigValues = currentAclConfig.values + const [aclData, aclOptions] = await currentAcl.getAclOptions( + aclConfigValues as never, // TODO: why is this conversion necessary? + logger, + ) const pollProps: CreatePollProps = { question: question.value, description: description.value, diff --git a/frontend/src/utils/poll.utils.ts b/frontend/src/utils/poll.utils.ts index bdac4a4..19e29fe 100644 --- a/frontend/src/utils/poll.utils.ts +++ b/frontend/src/utils/poll.utils.ts @@ -1,4 +1,4 @@ -import { AbiCoder, BytesLike, getAddress, getUint, JsonRpcProvider, ParamType } from 'ethers' +import { AbiCoder, BytesLike, getAddress, JsonRpcProvider, ParamType } from 'ethers' import { chain_info, @@ -6,34 +6,24 @@ import { xchainRPC, AclOptions, guessStorageSlot, - getBlockHeaderRLP, - fetchAccountProof, getNftContractType, ChainDefinition, - AclOptionsToken, AclOptionsXchain, - fetchStorageProof, IPollACL__factory, TokenInfo, - fetchStorageValue, NFTInfo, nftDetailsFromProvider, ContractType, } from '@oasisprotocol/blockvote-contracts' export type { ContractType, NftType } from '@oasisprotocol/blockvote-contracts' export { isToken } from '@oasisprotocol/blockvote-contracts' -import { - VITE_CONTRACT_ACL_ALLOWALL, - VITE_CONTRACT_ACL_STORAGEPROOF, - VITE_CONTRACT_ACL_TOKENHOLDER, - VITE_CONTRACT_ACL_VOTERALLOWLIST, -} from '../constants/config' import { Poll, PollManager } from '../types' import { encryptJSON } from './crypto.demo' import { Pinata } from './Pinata' import { EthereumContext } from '../providers/EthereumContext' import { DecisionWithReason, denyWithReason } from '../components/InputFields' import { FetcherFetchOptions } from './StoredLRUCache' +import { findACLForOptions } from '../components/ACLs' export { parseEther } from 'ethers' @@ -72,78 +62,11 @@ export const getSapphireTokenDetails = async (address: string) => { * * @returns DataHexstring */ -const abiEncode = (types: ReadonlyArray, values: ReadonlyArray): string => { +export const abiEncode = (types: ReadonlyArray, values: ReadonlyArray): string => { const abi = AbiCoder.defaultAbiCoder() return abi.encode(types, values) } -export const getAllowAllACLOptions = (): [string, AclOptions] => { - return [ - '0x', // Empty bytes is passed - { - address: VITE_CONTRACT_ACL_ALLOWALL, - options: { allowAll: true }, - }, - ] -} - -export const getAllowListAclOptions = (addresses: string[]): [string, AclOptions] => { - return [ - abiEncode(['address[]'], [addresses]), - { - address: VITE_CONTRACT_ACL_VOTERALLOWLIST, - options: { allowList: true }, - }, - ] -} - -export const getTokenHolderAclOptions = (tokenAddress: string): [string, AclOptions] => { - return [ - abiEncode(['address'], [tokenAddress]), - { - address: VITE_CONTRACT_ACL_TOKENHOLDER, - options: { token: tokenAddress }, - }, - ] -} - -export const getXchainAclOptions = async ( - props: { - chainId: number - contractAddress: string - slotNumber: number - blockHash: string - }, - updateStatus?: ((status: string | undefined) => void) | undefined, -): Promise<[string, AclOptions]> => { - const { chainId, contractAddress, slotNumber, blockHash } = props - const showStatus = updateStatus ?? ((message?: string | undefined) => console.log(message)) - 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, - }, - }, - }, - ] -} - export const getERC20TokenDetails = async (chainId: number, address: string) => { const rpc = xchainRPC(chainId) try { @@ -304,15 +227,16 @@ export type PollPermissions = { explanation: string | undefined canVote: DecisionWithReason canManage: boolean - tokenInfo: TokenInfo | NFTInfo | undefined - xChainOptions: AclOptionsXchain | undefined + tokenInfo?: TokenInfo | NFTInfo | undefined + xChainOptions?: AclOptionsXchain | undefined error: string } -export type CheckPermissionInputs = Pick & { +export type CheckPermissionInputs = { userAddress: string proposalId: string aclAddress: string + options: AclOptions } export type CheckPermissionContext = { @@ -329,103 +253,38 @@ export const checkPollPermission = async ( const { userAddress, proposalId, aclAddress, options } = input const pollACL = IPollACL__factory.connect(aclAddress, provider) - - let proof: BytesLike = '' - let explanation = '' - let canVote: DecisionWithReason = true const canManage = await pollACL.canManagePoll(daoAddress, proposalId, userAddress) - let error = '' - let tokenInfo: TokenInfo | NFTInfo | undefined = undefined - let xChainOptions: AclOptionsXchain | undefined = undefined - - const isAllowAll = 'allowAll' in options - const isTokenHolder = 'token' in options - const isWhitelist = 'allowList' in options - const isXChain = 'xchain' in options - - if (isAllowAll) { - proof = new Uint8Array() - const result = 0n !== (await pollACL.canVoteOnPoll(daoAddress, proposalId, userAddress, proof)) - if (result) { - canVote = true - explanation = '' - } else { - canVote = denyWithReason('some unknown reason') - } - } else if (isWhitelist) { - proof = new Uint8Array() - explanation = 'This poll is only for a predefined list of addresses.' - const result = 0n !== (await pollACL.canVoteOnPoll(daoAddress, proposalId, userAddress, proof)) - // console.log("whiteListAcl check:", result) - if (result) { - canVote = true - } else { - canVote = denyWithReason('you are not on the list of allowed addresses') + const acl = findACLForOptions(options) + + if (!acl) { + return { + proof: '', + explanation: '', + canVote: denyWithReason( + 'this poll has some unknown access control settings. (Poll created by newer version of software?)', + ), + tokenInfo: undefined, + xChainOptions: undefined, + error: '', + canManage, } - } else if (isTokenHolder) { - const tokenAddress = (options as AclOptionsToken).token - tokenInfo = await getSapphireTokenDetails(tokenAddress) - explanation = `You need to hold some ${tokenInfo?.name ?? 'specific'} token (on the Sapphire network) to vote.` - proof = new Uint8Array() - try { - const result = 0n !== (await pollACL.canVoteOnPoll(daoAddress, proposalId, userAddress, proof)) - // console.log("tokenHolderAcl check:", result) - if (result) { - canVote = true - } else { - canVote = denyWithReason(`you don't hold any ${tokenInfo?.name} tokens`) - } - } catch { - canVote = denyWithReason(`you don't hold any ${tokenInfo?.name} tokens`) - } - } else if (isXChain) { - xChainOptions = options as AclOptionsXchain - - const { - xchain: { chainId, blockHash, address: tokenAddress, slot }, - } = xChainOptions - const provider = xchainRPC(chainId) - const chainDefinition = getChainDefinition(chainId) - try { - tokenInfo = await getContractDetails(chainId, tokenAddress) - if (!tokenInfo) throw new Error("Can't load token details") - explanation = `This poll is only for those who have hold ${tokenInfo?.name} token on ${chainDefinition.name} when the poll was created.` - let isBalancePositive = false - const holderBalance = getUint( - await fetchStorageValue(provider, blockHash, tokenAddress, slot, userAddress), - ) - if (holderBalance > BigInt(0)) { - // Only attempt to get a proof if the balance is non-zero - proof = await fetchStorageProof(provider, blockHash, tokenAddress, slot, userAddress) - const result = await pollACL.canVoteOnPoll(daoAddress, proposalId, userAddress, proof) - if (0n !== result) { - isBalancePositive = true - canVote = true - } - } - if (!isBalancePositive) { - canVote = denyWithReason(`you don't hold any ${tokenInfo.name} tokens on ${chainDefinition.name}`) - } - } catch (e) { - const problem = e as any - error = problem.error?.message ?? problem.reason ?? problem.code ?? problem - console.error('Error when testing permission to vote on', proposalId, ':', error) - console.error('proof:', proof) - canVote = denyWithReason(`there was a technical problem verifying your permissions`) - if (fetchOptions) fetchOptions.ttl = 1000 - } - } else { - canVote = denyWithReason( - 'this poll has some unknown access control settings. (Poll created by newer version of software?)', - ) } + const { + canVote, + explanation, + proof, + error = '', + ...extra + } = await acl.checkPermission(pollACL, daoAddress, proposalId, userAddress, options as any) + + if (error && fetchOptions) fetchOptions.ttl = 1000 + return { proof, explanation, error, - tokenInfo, - xChainOptions, + ...extra, canVote, canManage, }