diff --git a/.changeset/calm-kiwis-mix.md b/.changeset/calm-kiwis-mix.md new file mode 100644 index 000000000..315fa8675 --- /dev/null +++ b/.changeset/calm-kiwis-mix.md @@ -0,0 +1,5 @@ +--- +"@snapshot-labs/sx": patch +--- + +add cancel proposal support to OffchainEthereumSig diff --git a/apps/ui/src/composables/useActions.ts b/apps/ui/src/composables/useActions.ts index f6e47505c..8d69a67d4 100644 --- a/apps/ui/src/composables/useActions.ts +++ b/apps/ui/src/composables/useActions.ts @@ -316,14 +316,19 @@ export function useActions() { } async function cancelProposal(proposal: Proposal) { - if (!web3.value.account) return await forceLogin(); + if (!web3.value.account) { + await forceLogin(); + return false; + } - const network = getReadWriteNetwork(proposal.network); + const network = getNetwork(proposal.network); if (!network.managerConnectors.includes(web3.value.type as Connector)) { throw new Error(`${web3.value.type} is not supported for this actions`); } await wrapPromise(proposal.network, network.actions.cancelProposal(auth.web3, proposal)); + + return true; } async function finalizeProposal(proposal: Proposal) { diff --git a/apps/ui/src/networks/offchain/actions.ts b/apps/ui/src/networks/offchain/actions.ts index ae1c823bc..0c16f5919 100644 --- a/apps/ui/src/networks/offchain/actions.ts +++ b/apps/ui/src/networks/offchain/actions.ts @@ -111,6 +111,12 @@ export function createActions( return client.updateProposal({ signer: web3.getSigner(), data }); }, + cancelProposal(web3: Web3Provider, proposal: Proposal) { + return client.cancel({ + signer: web3.getSigner(), + data: { proposal: proposal.proposal_id as string, space: proposal.space.id } + }); + }, vote( web3: Web3Provider, connectorType: Connector, diff --git a/apps/ui/src/networks/offchain/api/index.ts b/apps/ui/src/networks/offchain/api/index.ts index a1fabdba3..a2ae71615 100644 --- a/apps/ui/src/networks/offchain/api/index.ts +++ b/apps/ui/src/networks/offchain/api/index.ts @@ -55,7 +55,7 @@ function formatSpace(space: ApiSpace, networkId: NetworkID): Space { return { id: space.id, - controller: space.admins[0] ?? '', + controller: '', network: networkId, snapshot_chain_id: parseInt(space.network), name: space.name, @@ -136,7 +136,9 @@ function formatProposal(proposal: ApiProposal, networkId: NetworkID): Proposal { name: proposal.space.name, snapshot_chain_id: parseInt(proposal.space.network), avatar: '', - controller: proposal.space.admins[0] ?? '', + controller: '', + admins: proposal.space.admins, + moderators: proposal.space.moderators, voting_power_symbol: proposal.space.symbol, authenticators: [DEFAULT_AUTHENTICATOR], executors: [], diff --git a/apps/ui/src/networks/offchain/api/queries.ts b/apps/ui/src/networks/offchain/api/queries.ts index 2dc9c4bb4..e18b48c65 100644 --- a/apps/ui/src/networks/offchain/api/queries.ts +++ b/apps/ui/src/networks/offchain/api/queries.ts @@ -54,6 +54,7 @@ const PROPOSAL_FRAGMENT = gql` name network admins + moderators symbol } type diff --git a/apps/ui/src/networks/offchain/api/types.ts b/apps/ui/src/networks/offchain/api/types.ts index 508c2f88c..ffd58040b 100644 --- a/apps/ui/src/networks/offchain/api/types.ts +++ b/apps/ui/src/networks/offchain/api/types.ts @@ -49,6 +49,7 @@ export type ApiProposal = { name: string; network: string; admins: string[]; + moderators: string[]; symbol: string; }; type: VoteType; diff --git a/apps/ui/src/networks/offchain/constants.ts b/apps/ui/src/networks/offchain/constants.ts index e8c7c5626..de9f84955 100644 --- a/apps/ui/src/networks/offchain/constants.ts +++ b/apps/ui/src/networks/offchain/constants.ts @@ -1,3 +1,5 @@ +import { Connector } from '../types'; + export const AUTHS = {}; export const PROPOSAL_VALIDATIONS = { any: 'Any', @@ -9,6 +11,7 @@ export const PROPOSAL_VALIDATIONS = { }; export const STRATEGIES = {}; export const EXECUTORS = {}; +export const CONNECTORS: Connector[] = ['injected', 'walletconnect']; export const EDITOR_AUTHENTICATORS = []; export const EDITOR_PROPOSAL_VALIDATIONS = []; export const EDITOR_VOTING_STRATEGIES = []; diff --git a/apps/ui/src/networks/offchain/index.ts b/apps/ui/src/networks/offchain/index.ts index ffaeb3fad..b760075fc 100644 --- a/apps/ui/src/networks/offchain/index.ts +++ b/apps/ui/src/networks/offchain/index.ts @@ -70,7 +70,7 @@ export function createOffchainNetwork(networkId: NetworkID): Network { currentChainId: l1ChainId, hasReceive: false, supportsSimulation: false, - managerConnectors: [], + managerConnectors: constants.CONNECTORS, api, constants, helpers, diff --git a/apps/ui/src/networks/types.ts b/apps/ui/src/networks/types.ts index 4e3443afe..986caca38 100644 --- a/apps/ui/src/networks/types.ts +++ b/apps/ui/src/networks/types.ts @@ -109,6 +109,7 @@ export type ReadOnlyNetworkActions = { executionStrategy: string | null, transactions: MetaTransaction[] ): Promise; + cancelProposal(web3: Web3Provider, proposal: Proposal); vote( web3: Web3Provider, connectorType: Connector, @@ -145,7 +146,6 @@ export type NetworkActions = ReadOnlyNetworkActions & { } ); setMetadata(web3: Web3Provider, space: Space, metadata: SpaceMetadata); - cancelProposal(web3: Web3Provider, proposal: Proposal); finalizeProposal(web3: Web3Provider, proposal: Proposal); receiveProposal(web3: Web3Provider, proposal: Proposal); executeTransactions(web3: Web3Provider, proposal: Proposal); diff --git a/apps/ui/src/types.ts b/apps/ui/src/types.ts index c767f3992..7aaf63b5c 100644 --- a/apps/ui/src/types.ts +++ b/apps/ui/src/types.ts @@ -130,6 +130,8 @@ export type Proposal = { snapshot_chain_id?: number; avatar: string; controller: string; + admins?: string[]; + moderators?: string[]; voting_power_symbol: string; authenticators: string[]; executors: string[]; diff --git a/apps/ui/src/views/Proposal/Overview.vue b/apps/ui/src/views/Proposal/Overview.vue index a2ca5d34d..ee4457487 100644 --- a/apps/ui/src/views/Proposal/Overview.vue +++ b/apps/ui/src/views/Proposal/Overview.vue @@ -8,6 +8,7 @@ import { getUrl, getProposalId } from '@/helpers/utils'; +import { offchainNetworks } from '@/networks'; import { Proposal } from '@/types'; const props = defineProps<{ @@ -32,11 +33,21 @@ const editable = computed(() => { }); const cancellable = computed(() => { - return ( - compareAddresses(props.proposal.space.controller, web3.value.account) && - props.proposal.state !== 'executed' && - props.proposal.cancelled === false - ); + if (offchainNetworks.includes(props.proposal.network)) { + const addresses = [ + props.proposal.author.id, + props.proposal.space.admins || [], + props.proposal.space.moderators || [] + ].flat(); + + return addresses.some(address => compareAddresses(address, web3.value.account)); + } else { + return ( + compareAddresses(props.proposal.space.controller, web3.value.account) && + props.proposal.state !== 'executed' && + props.proposal.cancelled === false + ); + } }); const discussion = computed(() => { diff --git a/packages/sx.js/src/clients/offchain/ethereum-sig/index.ts b/packages/sx.js/src/clients/offchain/ethereum-sig/index.ts index e8ecb567b..057b4200b 100644 --- a/packages/sx.js/src/clients/offchain/ethereum-sig/index.ts +++ b/packages/sx.js/src/clients/offchain/ethereum-sig/index.ts @@ -5,19 +5,22 @@ import { basicVoteTypes, singleChoiceVoteTypes, approvalVoteTypes, - updateProposalTypes + updateProposalTypes, + cancelProposalTypes } from './types'; import type { Signer, TypedDataSigner, TypedDataField } from '@ethersproject/abstract-signer'; import type { + SignatureData, + Envelope, Vote, Propose, UpdateProposal, - Envelope, - SignatureData, - EIP712VoteMessage, + CancelProposal, EIP712Message, + EIP712VoteMessage, EIP712ProposeMessage, - EIP712UpdateProposal + EIP712UpdateProposal, + EIP712CancelProposalMessage } from '../types'; import type { OffchainNetworkConfig } from '../../../types'; @@ -40,7 +43,13 @@ export class EthereumSig { this.sequencerUrl = opts?.sequencerUrl || SEQUENCER_URLS[this.networkConfig.eip712ChainId]; } - public async sign( + public async sign< + T extends + | EIP712VoteMessage + | EIP712ProposeMessage + | EIP712UpdateProposal + | EIP712CancelProposalMessage + >( signer: Signer & TypedDataSigner, message: T, types: Record @@ -63,7 +72,7 @@ export class EthereumSig { }; } - public async send(envelope: Envelope) { + public async send(envelope: Envelope) { const { address, signature: sig, domain, types, message } = envelope.signatureData!; const payload = { address, @@ -126,6 +135,21 @@ export class EthereumSig { }; } + public async cancel({ + signer, + data + }: { + signer: Signer & TypedDataSigner; + data: CancelProposal; + }): Promise> { + const signatureData = await this.sign(signer, data, cancelProposalTypes); + + return { + signatureData, + data + }; + } + public async vote({ signer, data diff --git a/packages/sx.js/src/clients/offchain/ethereum-sig/types.ts b/packages/sx.js/src/clients/offchain/ethereum-sig/types.ts index d1145816b..a032e5c91 100644 --- a/packages/sx.js/src/clients/offchain/ethereum-sig/types.ts +++ b/packages/sx.js/src/clients/offchain/ethereum-sig/types.ts @@ -63,3 +63,12 @@ export const updateProposalTypes = { { name: 'plugins', type: 'string' } ] }; + +export const cancelProposalTypes = { + CancelProposal: [ + { name: 'from', type: 'address' }, + { name: 'space', type: 'string' }, + { name: 'timestamp', type: 'uint64' }, + { name: 'proposal', type: 'bytes32' } + ] +}; diff --git a/packages/sx.js/src/clients/offchain/types.ts b/packages/sx.js/src/clients/offchain/types.ts index c89d9cf46..ef15cd1b0 100644 --- a/packages/sx.js/src/clients/offchain/types.ts +++ b/packages/sx.js/src/clients/offchain/types.ts @@ -10,7 +10,7 @@ export type SignatureData = { message: Record; }; -export type Envelope = { +export type Envelope = { signatureData?: SignatureData; data: T; }; @@ -61,8 +61,15 @@ export type EIP712UpdateProposal = { from?: string; }; +export type EIP712CancelProposalMessage = { + space: string; + proposal: string; + from?: string; + timestamp?: number; +}; + export type EIP712Message = Required< - EIP712VoteMessage | EIP712ProposeMessage | EIP712UpdateProposal + EIP712VoteMessage | EIP712ProposeMessage | EIP712UpdateProposal | EIP712CancelProposalMessage >; export type Vote = { @@ -101,3 +108,10 @@ export type UpdateProposal = { choices: string[]; plugins: string; }; + +export type CancelProposal = { + from?: string; + space: string; + timestamp?: number; + proposal: string; +}; diff --git a/packages/sx.js/test/integration/offchain/index.test.ts b/packages/sx.js/test/integration/offchain/index.test.ts index 6d4a749f7..67bdccc03 100644 --- a/packages/sx.js/test/integration/offchain/index.test.ts +++ b/packages/sx.js/test/integration/offchain/index.test.ts @@ -6,6 +6,7 @@ import vote from './fixtures/vote.json'; import proposal from './fixtures/proposal.json'; // Test address: 0xf1f09AdC06aAB740AA16004D62Dbd89484d3Be90 +// This address only have the vote permissions on the testnet space wan-test.eth const TEST_PK = 'ef4bcf36b5d026b703b86a311031fe2291b979620f01443f795fa213f9105e35'; const signer = new Wallet(TEST_PK); const client = new EthereumSig({ networkConfig: offchainGoerli }); @@ -31,13 +32,27 @@ describe('vote', () => { }); describe('propose', () => { - it('should thrown an error when user does not have enough voting power', async () => { + it('should thrown an error when user can not create proposals', async () => { const currentTime = Math.floor(Date.now() / 1e3); const envelope = await client.propose({ signer, data: { ...proposal, start: currentTime, end: currentTime + 60 } }); - return expect(client.send(envelope)).rejects.toThrowError(/invalid voting power/); + return expect(client.send(envelope)).rejects.toThrowError(/validation failed/); + }); +}); + +describe('cancel', () => { + it('should thrown an error when user does not have permission', async () => { + const envelope = await client.cancel({ + signer, + data: { + space: 'fabien.eth', + proposal: '0x0d68ee21b493aec521a67dbec244d131e00cfa5eb6f97c9a0133d3e3f08cd7d4' + } + }); + + return expect(client.send(envelope)).rejects.toThrowError(/not authorized/); }); }); diff --git a/packages/sx.js/test/unit/clients/offchain/ethereum-sig/__snapshots__/index.test.ts.snap b/packages/sx.js/test/unit/clients/offchain/ethereum-sig/__snapshots__/index.test.ts.snap index 86f86af88..638db6850 100644 --- a/packages/sx.js/test/unit/clients/offchain/ethereum-sig/__snapshots__/index.test.ts.snap +++ b/packages/sx.js/test/unit/clients/offchain/ethereum-sig/__snapshots__/index.test.ts.snap @@ -1,5 +1,48 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`EthereumSig > should create cancelProposal envelope 1`] = ` +{ + "data": { + "proposal": "0x56b857b02d573b0ba747333b57cb3dd11df57cc0d1bcc41c3c990466b477c5e8", + "space": "test.eth", + }, + "signatureData": { + "address": "0xf1f09AdC06aAB740AA16004D62Dbd89484d3Be90", + "domain": { + "name": "snapshot", + "version": "0.1.4", + }, + "message": { + "from": "0xf1f09AdC06aAB740AA16004D62Dbd89484d3Be90", + "proposal": "0x56b857b02d573b0ba747333b57cb3dd11df57cc0d1bcc41c3c990466b477c5e8", + "space": "test.eth", + "timestamp": 1705795200, + }, + "signature": "0xe358de8e39c6c0cc9df929892e61806371e6539199a2b0781b9100700760067334024ca8cd5f86f1cb16fc93e76602989618eeacebf1fafbc1acad882d14b4d61c", + "types": { + "CancelProposal": [ + { + "name": "from", + "type": "address", + }, + { + "name": "space", + "type": "string", + }, + { + "name": "timestamp", + "type": "uint64", + }, + { + "name": "proposal", + "type": "bytes32", + }, + ], + }, + }, +} +`; + exports[`EthereumSig > should create propose envelope 1`] = ` { "data": { diff --git a/packages/sx.js/test/unit/clients/offchain/ethereum-sig/index.test.ts b/packages/sx.js/test/unit/clients/offchain/ethereum-sig/index.test.ts index 7d4d993a9..25926bfd9 100644 --- a/packages/sx.js/test/unit/clients/offchain/ethereum-sig/index.test.ts +++ b/packages/sx.js/test/unit/clients/offchain/ethereum-sig/index.test.ts @@ -60,4 +60,18 @@ describe('EthereumSig', () => { expect(envelope).toMatchSnapshot(); }); + + it('should create cancelProposal envelope', async () => { + const payload = { + space: 'test.eth', + proposal: '0x56b857b02d573b0ba747333b57cb3dd11df57cc0d1bcc41c3c990466b477c5e8' + }; + + const envelope = await client.cancel({ + signer, + data: payload + }); + + expect(envelope).toMatchSnapshot(); + }); });