From 827b7b30d4a312853200c9508dfdac833712d070 Mon Sep 17 00:00:00 2001 From: Wan <495709+wa0x6e@users.noreply.github.com> Date: Mon, 19 Feb 2024 20:50:17 +0800 Subject: [PATCH] feat: add basic voting for offchain spaces (#36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: show voting buttons for offchain proposals * refactor: move the network actions to actions.ts * feat: add support for basic voting for offchain proposals * fix: fix invalid import type * fix: use correct sequencer network depending on chainId * chore: add tests * Update packages/sx.js/src/clients/offchain/ethereum-sig/index.ts Co-authored-by: Wiktor Tkaczyński * refactor: use same function order definition as other networks * fix: handle offchain receipt without tx_hash * fix: fix typing * fix: throw error on sequencer error response * chore: add unit test * chore: fix tests * fix: show voting form only for supported voting type * chore: remove cumbersome describe block * fix: switch to correct chain for offchain actions * fix: avoid changing type * chore: improve test * fix: revert network enforcer * fix: remove unused import * Update packages/sx.js/src/clients/starknet/starknet-tx/index.ts Co-authored-by: Wiktor Tkaczyński * chore: update changeset --------- Co-authored-by: Wiktor Tkaczyński --- .changeset/cuddly-parrots-obey.md | 5 + apps/ui/src/components/ProposalVote.vue | 2 +- apps/ui/src/composables/useActions.ts | 12 +- apps/ui/src/networks/offchain/actions.ts | 95 ++++++++++++++++ apps/ui/src/networks/offchain/api/index.ts | 6 +- apps/ui/src/networks/offchain/helpers.ts | 7 ++ apps/ui/src/networks/offchain/index.ts | 62 ++--------- apps/ui/src/networks/types.ts | 18 +-- apps/ui/src/views/Proposal.vue | 67 ++++++------ packages/sx.js/package.json | 3 +- packages/sx.js/src/clients/index.ts | 1 + .../clients/offchain/ethereum-sig/index.ts | 103 ++++++++++++++++++ .../clients/offchain/ethereum-sig/types.ts | 17 +++ packages/sx.js/src/clients/offchain/types.ts | 46 ++++++++ packages/sx.js/src/networks.ts | 1 + packages/sx.js/src/offchainNetworks.ts | 8 ++ packages/sx.js/src/types/networkConfig.ts | 4 + .../integration/offchain/fixtures/vote.json | 8 ++ .../test/integration/offchain/index.test.ts | 29 +++++ .../__snapshots__/index.test.ts.snap | 68 ++++++++++++ .../offchain/ethereum-sig/index.test.ts | 38 +++++++ 21 files changed, 498 insertions(+), 102 deletions(-) create mode 100644 .changeset/cuddly-parrots-obey.md create mode 100644 apps/ui/src/networks/offchain/actions.ts create mode 100644 apps/ui/src/networks/offchain/helpers.ts create mode 100644 packages/sx.js/src/clients/offchain/ethereum-sig/index.ts create mode 100644 packages/sx.js/src/clients/offchain/ethereum-sig/types.ts create mode 100644 packages/sx.js/src/clients/offchain/types.ts create mode 100644 packages/sx.js/src/offchainNetworks.ts create mode 100644 packages/sx.js/test/integration/offchain/fixtures/vote.json create mode 100644 packages/sx.js/test/integration/offchain/index.test.ts create mode 100644 packages/sx.js/test/unit/clients/offchain/ethereum-sig/__snapshots__/index.test.ts.snap create mode 100644 packages/sx.js/test/unit/clients/offchain/ethereum-sig/index.test.ts diff --git a/.changeset/cuddly-parrots-obey.md b/.changeset/cuddly-parrots-obey.md new file mode 100644 index 000000000..b3950579f --- /dev/null +++ b/.changeset/cuddly-parrots-obey.md @@ -0,0 +1,5 @@ +--- +"@snapshot-labs/sx": minor +--- + +add OffchainEthereumSig client with basic vote support diff --git a/apps/ui/src/components/ProposalVote.vue b/apps/ui/src/components/ProposalVote.vue index b6c487fb7..61458c208 100644 --- a/apps/ui/src/components/ProposalVote.vue +++ b/apps/ui/src/components/ProposalVote.vue @@ -21,7 +21,7 @@ const isSupported = computed(() => { network.helpers.isStrategySupported(strategy) ); - return hasSupportedAuthenticator && hasSupportedStrategies; + return hasSupportedAuthenticator && hasSupportedStrategies && props.proposal.type === 'basic'; }); diff --git a/apps/ui/src/composables/useActions.ts b/apps/ui/src/composables/useActions.ts index 81dc49c90..a8d71b892 100644 --- a/apps/ui/src/composables/useActions.ts +++ b/apps/ui/src/composables/useActions.ts @@ -1,5 +1,5 @@ import { getInstance } from '@snapshot-labs/lock/plugins/vue3'; -import { getReadWriteNetwork } from '@/networks'; +import { getNetwork, getReadWriteNetwork } from '@/networks'; import { registerTransaction } from '@/helpers/mana'; import { convertToMetaTransactions } from '@/helpers/transactions'; import type { @@ -49,7 +49,7 @@ export function useActions() { async function handleCommitEnvelope(envelope: any, networkId: NetworkID) { // TODO: it should work with WalletConnect, should be done before L1 transaction is broadcasted - const network = getReadWriteNetwork(networkId); + const network = getNetwork(networkId); if (envelope?.signatureData?.commitHash && network.baseNetworkId) { await registerTransaction(network.chainId, { @@ -74,7 +74,7 @@ export function useActions() { } async function wrapPromise(networkId: NetworkID, promise: Promise) { - const network = getReadWriteNetwork(networkId); + const network = getNetwork(networkId); const envelope = await promise; @@ -84,9 +84,11 @@ export function useActions() { // TODO: unify send/soc to both return txHash under same property if (envelope.signatureData || envelope.sig) { const receipt = await network.actions.send(envelope); + const hash = receipt.transaction_hash || receipt.hash; console.log('Receipt', receipt); - uiStore.addPendingTransaction(receipt.transaction_hash || receipt.hash, networkId); + + hash && uiStore.addPendingTransaction(hash, networkId); } else { uiStore.addPendingTransaction(envelope.transaction_hash || envelope.hash, networkId); } @@ -185,7 +187,7 @@ export function useActions() { async function vote(proposal: Proposal, choice: Choice) { if (!web3.value.account) return await forceLogin(); - const network = getReadWriteNetwork(proposal.network); + const network = getNetwork(proposal.network); await wrapPromise( proposal.network, diff --git a/apps/ui/src/networks/offchain/actions.ts b/apps/ui/src/networks/offchain/actions.ts new file mode 100644 index 000000000..df21b91e5 --- /dev/null +++ b/apps/ui/src/networks/offchain/actions.ts @@ -0,0 +1,95 @@ +import { OffchainNetworkConfig, clients, offchainGoerli, offchainMainnet } from '@snapshot-labs/sx'; +import { getSdkChoice } from './helpers'; +import type { Web3Provider } from '@ethersproject/providers'; +import type { StrategyParsedMetadata, Choice, Proposal } from '@/types'; +import type { + ReadOnlyNetworkActions, + NetworkConstants, + NetworkHelpers, + SnapshotInfo, + VotingPower, + Connector +} from '../types'; + +const SCORE_URL = 'https://score.snapshot.org'; +const CONFIGS: Record = { + 1: offchainMainnet, + 5: offchainGoerli +}; + +export function createActions( + constants: NetworkConstants, + helpers: NetworkHelpers, + chainId: number +): ReadOnlyNetworkActions { + const networkConfig = CONFIGS[chainId]; + + const client = new clients.OffchainEthereumSig({ + networkConfig + }); + + return { + vote( + web3: Web3Provider, + connectorType: Connector, + account: string, + proposal: Proposal, + choice: Choice + ): Promise { + const data = { + space: proposal.space.id, + proposal: proposal.proposal_id as string, + choice: getSdkChoice(choice), + authenticator: '', + strategies: [], + metadataUri: '' + }; + + return client.vote({ + signer: web3.getSigner(), + data + }); + }, + send: (envelope: any) => client.send(envelope), + getVotingPower: async ( + strategiesAddresses: string[], + strategiesParams: any[], + strategiesMetadata: StrategyParsedMetadata[], + voterAddress: string, + snapshotInfo: SnapshotInfo + ): Promise => { + const result = await fetch(SCORE_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + method: 'get_vp', + params: { + address: voterAddress, + space: '', + strategies: strategiesParams, + network: snapshotInfo.chainId ?? chainId, + snapshot: snapshotInfo.at ?? 'latest' + } + }) + }); + const body = await result.json(); + + return body.result.vp_by_strategy.map((vp: number, index: number) => { + const strategy = strategiesParams[index]; + const decimals = parseInt(strategy.params.decimals || 0); + + return { + address: strategy.name, + value: BigInt(vp * 10 ** decimals), + decimals, + symbol: strategy.params.symbol, + token: strategy.params.address, + chainId: strategy.network ? parseInt(strategy.network) : undefined + } as VotingPower; + }); + } + }; +} diff --git a/apps/ui/src/networks/offchain/api/index.ts b/apps/ui/src/networks/offchain/api/index.ts index 0f9d5983b..cfa51216a 100644 --- a/apps/ui/src/networks/offchain/api/index.ts +++ b/apps/ui/src/networks/offchain/api/index.ts @@ -11,6 +11,8 @@ import { getNames } from '@/helpers/stamp'; import { Space, Proposal, Vote, User, NetworkID, ProposalState } from '@/types'; import { ApiSpace, ApiProposal, ApiVote } from './types'; +const DEFAULT_AUTHENTICATOR = 'OffchainAuthenticator'; + function getProposalState(proposal: ApiProposal): ProposalState { if (proposal.state === 'closed') { if (proposal.scores_total < proposal.quorum) return 'rejected'; @@ -61,7 +63,7 @@ function formatSpace(space: ApiSpace, networkId: NetworkID): Space { : [], // NOTE: ignored created: 0, - authenticators: [], + authenticators: [DEFAULT_AUTHENTICATOR], executors: [], executors_types: [], strategies: space.strategies.map(strategy => strategy.name), @@ -111,7 +113,7 @@ function formatProposal(proposal: ApiProposal, networkId: NetworkID): Proposal { avatar: '', controller: proposal.space.admins[0] ?? '', voting_power_symbol: proposal.space.symbol, - authenticators: [], + authenticators: [DEFAULT_AUTHENTICATOR], executors: [], executors_types: [], strategies_parsed_metadata: [] diff --git a/apps/ui/src/networks/offchain/helpers.ts b/apps/ui/src/networks/offchain/helpers.ts new file mode 100644 index 000000000..c68fae6de --- /dev/null +++ b/apps/ui/src/networks/offchain/helpers.ts @@ -0,0 +1,7 @@ +import { Choice } from '@/types'; + +export function getSdkChoice(choice: Choice): number { + if (choice === 'for') return 1; + if (choice === 'against') return 2; + return 3; +} diff --git a/apps/ui/src/networks/offchain/index.ts b/apps/ui/src/networks/offchain/index.ts index 8b65f8b14..72fc4b84d 100644 --- a/apps/ui/src/networks/offchain/index.ts +++ b/apps/ui/src/networks/offchain/index.ts @@ -1,33 +1,36 @@ import { createApi } from './api'; import * as constants from './constants'; +import { createActions } from './actions'; import { pinPineapple } from '@/helpers/pin'; -import { Network, VotingPower, SnapshotInfo } from '@/networks/types'; -import { NetworkID, StrategyParsedMetadata } from '@/types'; +import { Network } from '@/networks/types'; +import { NetworkID } from '@/types'; import networks from '@/helpers/networks.json'; const HUB_URLS: Partial> = { s: 'https://hub.snapshot.org/graphql', 's-tn': 'https://testnet.hub.snapshot.org/graphql' }; -const SCORE_URL = 'https://score.snapshot.org'; const SNAPSHOT_URLS: Partial> = { s: 'https://snapshot.org', 's-tn': 'https://testnet.snapshot.org' }; +const CHAIN_IDS: Partial> = { + s: 1, + 's-tn': 5 +}; export function createOffchainNetwork(networkId: NetworkID): Network { - const l1ChainId = 1; - + const l1ChainId = CHAIN_IDS[networkId]; const hubUrl = HUB_URLS[networkId]; - if (!hubUrl) throw new Error(`Unknown network ${networkId}`); + if (!hubUrl || !l1ChainId) throw new Error(`Unknown network ${networkId}`); const api = createApi(hubUrl, networkId); const helpers = { - isAuthenticatorSupported: () => false, + isAuthenticatorSupported: () => true, isAuthenticatorContractSupported: () => false, getRelayerAuthenticatorType: () => null, - isStrategySupported: () => false, + isStrategySupported: () => true, isExecutorSupported: () => false, pin: pinPineapple, waitForTransaction: () => { @@ -70,47 +73,6 @@ export function createOffchainNetwork(networkId: NetworkID): Network { api, constants, helpers, - actions: { - getVotingPower: async ( - strategiesAddresses: string[], - strategiesParams: any[], - strategiesMetadata: StrategyParsedMetadata[], - voterAddress: string, - snapshotInfo: SnapshotInfo - ): Promise => { - const result = await fetch(SCORE_URL, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - method: 'get_vp', - params: { - address: voterAddress, - space: '', - strategies: strategiesParams, - network: snapshotInfo.chainId ?? l1ChainId, - snapshot: snapshotInfo.at ?? 'latest' - } - }) - }); - const body = await result.json(); - - return body.result.vp_by_strategy.map((vp: number, index: number) => { - const strategy = strategiesParams[index]; - const decimals = parseInt(strategy.params.decimals || 0); - - return { - address: strategy.name, - value: BigInt(vp * 10 ** decimals), - decimals, - symbol: strategy.params.symbol, - token: strategy.params.address, - chainId: strategy.network ? parseInt(strategy.network) : undefined - } as VotingPower; - }); - } - } + actions: createActions(constants, helpers, l1ChainId) }; } diff --git a/apps/ui/src/networks/types.ts b/apps/ui/src/networks/types.ts index d379ede2a..8fec2b5db 100644 --- a/apps/ui/src/networks/types.ts +++ b/apps/ui/src/networks/types.ts @@ -79,7 +79,7 @@ export type VotingPower = { // TODO: make sx.js accept Signer instead of Web3Provider | Wallet -type ReadOnlyNetworkActions = { +export type ReadOnlyNetworkActions = { getVotingPower( strategiesAddresses: string[], strategiesParams: any[], @@ -87,6 +87,14 @@ type ReadOnlyNetworkActions = { voterAddress: string, snapshotInfo: SnapshotInfo ): Promise; + vote( + web3: Web3Provider, + connectorType: Connector, + account: string, + proposal: Proposal, + choice: Choice + ): Promise; + send(envelope: any): Promise; }; export type NetworkActions = ReadOnlyNetworkActions & { @@ -135,13 +143,6 @@ export type NetworkActions = ReadOnlyNetworkActions & { transactions: MetaTransaction[] ); cancelProposal(web3: Web3Provider, proposal: Proposal); - vote( - web3: Web3Provider, - connectorType: Connector, - account: string, - proposal: Proposal, - choice: Choice - ); finalizeProposal(web3: Web3Provider, proposal: Proposal); receiveProposal(web3: Web3Provider, proposal: Proposal); executeTransactions(web3: Web3Provider, proposal: Proposal); @@ -167,7 +168,6 @@ export type NetworkActions = ReadOnlyNetworkActions & { delegatee: string, delegationContract: string ); - send(envelope: any): Promise; }; export type NetworkApi = { diff --git a/apps/ui/src/views/Proposal.vue b/apps/ui/src/views/Proposal.vue index 5efbf95c5..45c128abf 100644 --- a/apps/ui/src/views/Proposal.vue +++ b/apps/ui/src/views/Proposal.vue @@ -159,40 +159,39 @@ watchEffect(() => { - + +

Cast your vote

+ +
+ + + + + + + + + + + + + + + +
+