Skip to content

Commit

Permalink
feat: add support for offchain proposal cancel (#76)
Browse files Browse the repository at this point in the history
* feat: add support for offchain basic proposal creation

* fix: set voting delay and period when available

* fix: get discussion from payload

* chore: remove unused variable/import

* refactor: extract value to constant

* fix: get correct block number on proposal creation

* fix: add support for only members proposal validation

* fix: fetch voting power for proposal creation

* refactor: improve readability

* refactor: improve code

* fix: remove debug value

* fix: fix timestamp mismatch when using voting delay/period

* chore: fix import order

* fix: address comparison should not depend on case

* fix: add try/catch to fetch call

* feat: add vote type selector to proposal editor

* Update apps/ui/src/networks/offchain/actions.ts

Co-authored-by: Wiktor Tkaczyński <[email protected]>

* Update apps/ui/src/networks/offchain/actions.ts

Co-authored-by: Wiktor Tkaczyński <[email protected]>

* refactor: remove redundant else

* fix: fix casing

* refactor: rename for consistency

* fix: show choices selector

* refactor: DRY types name

* fix: rename duplicate type

* fix: fix tests

* feat: add support for offchain proposal cancel

* fix: add choices customization

* fix: fix botched merge

* fix: add choices customization

* fix: add support for choices reordering

* refactor: extract vote types/choices to its own component

* fix(ui): implement spacing from design system

* fix(ui): show error message on empty choices

* fix: validate choices through JSON validation

* fix(ui): add support for dark theme

* refactor: code cleanup

* chore: code cleanup

* refactor: refactoring

* refactor: remove unused value

* refactor: rename constant to reflect its type

* Update apps/ui/src/components/EditorVote.vue

Co-authored-by: Wiktor Tkaczyński <[email protected]>

* fix: display voting type/choices depending on network support

* fix(ui): fix for small screen

* fix: fix missing item-key for draggable component

* refactor:

* chore: fix strictMode warning

* fix(ui): UI fixes to conform to figma

* fix(ui): remove uneeded import

* fix(ui): reset choices when changing vote type

* refactor: split EditorVote type into 2

* refactor: extract to UiSelector component

* fix(ui): increase border radius

* fix(ui): default to 3 empty choices for other voting type

* fix(ui): use same placeholder for errored input

* fix(ui): default to 2 empty choices

* fix(ui): do not show error for empty choices

* fix(ui): add illustration image for voting type

* fix(ui): add animation to draggable

* fix(ux): add validation to choices

* fix(ui): keep same styling as existing form imput

* refactor: remove unused props

* fix(ui): add margin

* chore: fix tests

* fix: fix function return signature

* chore: remove debug output

* fix: function should return signature envelope

* chore: update changeset

* chore: remove unused import

* fix: use dedicated set of permission for offchain proposal deletion

* fix: fix typing

* chore: fix import order

* chore: add unit tests for offchain eth-sig proposal cancel

* chore: add integration test for offchain eth-sig cancel

* fix: remove false assumption that controller is also admin

---------

Co-authored-by: Wiktor Tkaczyński <[email protected]>
  • Loading branch information
wa0x6e and Sekhmet authored Mar 4, 2024
1 parent a91bc04 commit 7703dde
Show file tree
Hide file tree
Showing 17 changed files with 177 additions and 22 deletions.
5 changes: 5 additions & 0 deletions .changeset/calm-kiwis-mix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@snapshot-labs/sx": patch
---

add cancel proposal support to OffchainEthereumSig
9 changes: 7 additions & 2 deletions apps/ui/src/composables/useActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions apps/ui/src/networks/offchain/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions apps/ui/src/networks/offchain/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: [],
Expand Down
1 change: 1 addition & 0 deletions apps/ui/src/networks/offchain/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const PROPOSAL_FRAGMENT = gql`
name
network
admins
moderators
symbol
}
type
Expand Down
1 change: 1 addition & 0 deletions apps/ui/src/networks/offchain/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export type ApiProposal = {
name: string;
network: string;
admins: string[];
moderators: string[];
symbol: string;
};
type: VoteType;
Expand Down
3 changes: 3 additions & 0 deletions apps/ui/src/networks/offchain/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Connector } from '../types';

export const AUTHS = {};
export const PROPOSAL_VALIDATIONS = {
any: 'Any',
Expand All @@ -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 = [];
Expand Down
2 changes: 1 addition & 1 deletion apps/ui/src/networks/offchain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function createOffchainNetwork(networkId: NetworkID): Network {
currentChainId: l1ChainId,
hasReceive: false,
supportsSimulation: false,
managerConnectors: [],
managerConnectors: constants.CONNECTORS,
api,
constants,
helpers,
Expand Down
2 changes: 1 addition & 1 deletion apps/ui/src/networks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export type ReadOnlyNetworkActions = {
executionStrategy: string | null,
transactions: MetaTransaction[]
): Promise<any>;
cancelProposal(web3: Web3Provider, proposal: Proposal);
vote(
web3: Web3Provider,
connectorType: Connector,
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions apps/ui/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
21 changes: 16 additions & 5 deletions apps/ui/src/views/Proposal/Overview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getUrl,
getProposalId
} from '@/helpers/utils';
import { offchainNetworks } from '@/networks';
import { Proposal } from '@/types';
const props = defineProps<{
Expand All @@ -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(() => {
Expand Down
38 changes: 31 additions & 7 deletions packages/sx.js/src/clients/offchain/ethereum-sig/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -40,7 +43,13 @@ export class EthereumSig {
this.sequencerUrl = opts?.sequencerUrl || SEQUENCER_URLS[this.networkConfig.eip712ChainId];
}

public async sign<T extends EIP712VoteMessage | EIP712ProposeMessage | EIP712UpdateProposal>(
public async sign<
T extends
| EIP712VoteMessage
| EIP712ProposeMessage
| EIP712UpdateProposal
| EIP712CancelProposalMessage
>(
signer: Signer & TypedDataSigner,
message: T,
types: Record<string, TypedDataField[]>
Expand All @@ -63,7 +72,7 @@ export class EthereumSig {
};
}

public async send(envelope: Envelope<Vote | Propose | UpdateProposal>) {
public async send(envelope: Envelope<Vote | Propose | UpdateProposal | CancelProposal>) {
const { address, signature: sig, domain, types, message } = envelope.signatureData!;
const payload = {
address,
Expand Down Expand Up @@ -126,6 +135,21 @@ export class EthereumSig {
};
}

public async cancel({
signer,
data
}: {
signer: Signer & TypedDataSigner;
data: CancelProposal;
}): Promise<Envelope<CancelProposal>> {
const signatureData = await this.sign(signer, data, cancelProposalTypes);

return {
signatureData,
data
};
}

public async vote({
signer,
data
Expand Down
9 changes: 9 additions & 0 deletions packages/sx.js/src/clients/offchain/ethereum-sig/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
]
};
18 changes: 16 additions & 2 deletions packages/sx.js/src/clients/offchain/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export type SignatureData = {
message: Record<string, any>;
};

export type Envelope<T extends Vote | Propose | UpdateProposal> = {
export type Envelope<T extends Vote | Propose | UpdateProposal | CancelProposal> = {
signatureData?: SignatureData;
data: T;
};
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -101,3 +108,10 @@ export type UpdateProposal = {
choices: string[];
plugins: string;
};

export type CancelProposal = {
from?: string;
space: string;
timestamp?: number;
proposal: string;
};
19 changes: 17 additions & 2 deletions packages/sx.js/test/integration/offchain/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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/);
});
});
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

0 comments on commit 7703dde

Please sign in to comment.