diff --git a/.changeset/breezy-owls-clean.md b/.changeset/breezy-owls-clean.md
new file mode 100644
index 000000000..6b9d144e8
--- /dev/null
+++ b/.changeset/breezy-owls-clean.md
@@ -0,0 +1,5 @@
+---
+"@snapshot-labs/sx": patch
+---
+
+support submitting a reason when voting
diff --git a/apps/ui/src/components/IndicatorVotingPower.vue b/apps/ui/src/components/IndicatorVotingPower.vue
index 32c50f3be..428260e45 100644
--- a/apps/ui/src/components/IndicatorVotingPower.vue
+++ b/apps/ui/src/components/IndicatorVotingPower.vue
@@ -1,40 +1,29 @@
+
+
+
+
+ There was an error fetching your voting power.
+
+
+ Retry
+
+
+
+ You do not have enough voting power to vote.
+
+
+ You do not have enough voting power to create proposal in this space.
+
+
diff --git a/apps/ui/src/components/Modal/Vote.vue b/apps/ui/src/components/Modal/Vote.vue
new file mode 100644
index 000000000..9555b9afd
--- /dev/null
+++ b/apps/ui/src/components/Modal/Vote.vue
@@ -0,0 +1,168 @@
+
+
+
+
+
+ Cast your vote
+
+
+
+
+ - Choice
+ -
+
+
+
+ No choice selected
+
+
+ - Voting power
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ Confirm
+
+
+
+
+
diff --git a/apps/ui/src/components/Modal/VotingPower.vue b/apps/ui/src/components/Modal/VotingPower.vue
index 76de4cfe0..688ebc73e 100644
--- a/apps/ui/src/components/Modal/VotingPower.vue
+++ b/apps/ui/src/components/Modal/VotingPower.vue
@@ -2,21 +2,18 @@
import { _n, shorten } from '@/helpers/utils';
import { addressValidator as isValidAddress } from '@/helpers/validation';
import { getNetwork } from '@/networks';
-import { VotingPower, VotingPowerStatus } from '@/networks/types';
+import { VotingPowerItem } from '@/stores/votingPowers';
import { NetworkID } from '@/types';
const props = defineProps<{
open: boolean;
networkId: NetworkID;
- votingPowerSymbol: string;
- votingPowers: VotingPower[];
- votingPowerStatus: VotingPowerStatus;
- finalDecimals: number;
+ votingPower?: VotingPowerItem;
}>();
defineEmits<{
(e: 'close');
- (e: 'getVotingPower');
+ (e: 'fetchVotingPower');
}>();
const network = computed(() => getNetwork(props.networkId));
@@ -25,8 +22,9 @@ const baseNetwork = computed(() =>
? getNetwork(network.value.baseNetworkId)
: network.value
);
-const loading = computed(() => props.votingPowerStatus === 'loading');
-const error = computed(() => props.votingPowerStatus === 'error');
+const loading = computed(
+ () => !props.votingPower || props.votingPower.status === 'loading'
+);
@@ -35,21 +33,14 @@ const error = computed(() => props.votingPowerStatus === 'error');
Your voting power
-
-
- There was an error fetching your voting power.
-
- Retry
-
-
+
+
@@ -67,12 +58,16 @@ const error = computed(() => props.votingPowerStatus === 'error');
/>
{{
- _n(Number(strategy.value) / 10 ** finalDecimals, 'compact', {
- maximumFractionDigits: 2,
- formatDust: true
- })
+ _n(
+ Number(strategy.value) / 10 ** votingPower.decimals,
+ 'compact',
+ {
+ maximumFractionDigits: 2,
+ formatDust: true
+ }
+ )
}}
- {{ votingPowerSymbol }}
+ {{ votingPower.symbol }}
diff --git a/apps/ui/src/components/ProposalVoteApproval.vue b/apps/ui/src/components/ProposalVoteApproval.vue
index aebeebbcd..3f348387b 100644
--- a/apps/ui/src/components/ProposalVoteApproval.vue
+++ b/apps/ui/src/components/ProposalVoteApproval.vue
@@ -4,7 +4,6 @@ import { Choice, Proposal } from '@/types';
type ApprovalChoice = number[];
const props = defineProps<{
- sendingType: Choice | null;
proposal: Proposal;
defaultChoice?: Choice;
}>();
@@ -45,7 +44,6 @@ function toggleSelectedChoice(choice: number) {
Vote
diff --git a/apps/ui/src/components/ProposalVoteBasic.vue b/apps/ui/src/components/ProposalVoteBasic.vue
index cab3ff8f5..da0c9dba5 100644
--- a/apps/ui/src/components/ProposalVoteBasic.vue
+++ b/apps/ui/src/components/ProposalVoteBasic.vue
@@ -3,7 +3,6 @@ import { Choice } from '@/types';
withDefaults(
defineProps<{
- sendingType: Choice | null;
size?: number;
}>(),
{ size: 48 }
@@ -23,7 +22,6 @@ const emit = defineEmits<{
'!size-[48px]': size === 48,
'!size-[40px]': size === 40
}"
- :loading="sendingType === 'for'"
@click="emit('vote', 'for')"
>
@@ -36,7 +34,6 @@ const emit = defineEmits<{
'!size-[48px]': size === 48,
'!size-[40px]': size === 40
}"
- :loading="sendingType === 'against'"
@click="emit('vote', 'against')"
>
@@ -49,7 +46,6 @@ const emit = defineEmits<{
'!size-[48px]': size === 48,
'!size-[40px]': size === 40
}"
- :loading="sendingType === 'abstain'"
@click="emit('vote', 'abstain')"
>
diff --git a/apps/ui/src/components/ProposalVoteRankedChoice.vue b/apps/ui/src/components/ProposalVoteRankedChoice.vue
index 2f62a1e67..2375e3aef 100644
--- a/apps/ui/src/components/ProposalVoteRankedChoice.vue
+++ b/apps/ui/src/components/ProposalVoteRankedChoice.vue
@@ -5,7 +5,6 @@ import { Choice, Proposal } from '@/types';
type RankedChoice = number[];
const props = defineProps<{
- sendingType: Choice | null;
proposal: Proposal;
defaultChoice?: Choice;
}>();
@@ -49,7 +48,6 @@ const selectedChoices = ref(
Vote
diff --git a/apps/ui/src/components/ProposalVoteSingleChoice.vue b/apps/ui/src/components/ProposalVoteSingleChoice.vue
index a6e10d93c..747345d31 100644
--- a/apps/ui/src/components/ProposalVoteSingleChoice.vue
+++ b/apps/ui/src/components/ProposalVoteSingleChoice.vue
@@ -2,7 +2,6 @@
import { Choice, Proposal } from '@/types';
const props = defineProps<{
- sendingType: Choice | null;
proposal: Proposal;
defaultChoice?: Choice;
}>();
@@ -33,7 +32,6 @@ const selectedChoice = ref(
diff --git a/apps/ui/src/components/ProposalVoteWeighted.vue b/apps/ui/src/components/ProposalVoteWeighted.vue
index b418168c8..b20e7a864 100644
--- a/apps/ui/src/components/ProposalVoteWeighted.vue
+++ b/apps/ui/src/components/ProposalVoteWeighted.vue
@@ -5,7 +5,6 @@ import { Choice, Proposal } from '@/types';
type WeightedChoice = Record;
const props = defineProps<{
- sendingType: Choice | null;
proposal: Proposal;
defaultChoice?: Choice;
}>();
@@ -90,7 +89,6 @@ watch(
Vote
diff --git a/apps/ui/src/components/ProposalsListItem.vue b/apps/ui/src/components/ProposalsListItem.vue
index 141b9ffa6..323ca66cb 100644
--- a/apps/ui/src/components/ProposalsListItem.vue
+++ b/apps/ui/src/components/ProposalsListItem.vue
@@ -6,22 +6,25 @@ import { Choice, Proposal as ProposalType } from '@/types';
const props = defineProps<{ proposal: ProposalType; showSpace: boolean }>();
const { getTsFromCurrent } = useMetaStore();
-const { vote } = useActions();
+const { modalAccountOpen } = useModal();
+const { web3 } = useWeb3();
+
const { votes } = useAccount();
const modalOpenTimeline = ref(false);
-const sendingType = ref(null);
+const modalOpenVote = ref(false);
+const selectedChoice = ref(null);
const totalProgress = computed(() => quorumProgress(props.proposal));
-async function handleVoteClick(choice: Choice) {
- sendingType.value = choice;
-
- try {
- await vote(props.proposal, choice);
- } finally {
- sendingType.value = null;
+const handleVoteClick = (choice: Choice) => {
+ if (!web3.value.account) {
+ modalAccountOpen.value = true;
+ return;
}
-}
+
+ selectedChoice.value = choice;
+ modalOpenVote.value = true;
+};
@@ -136,7 +139,6 @@ async function handleVoteClick(choice: Choice) {
@@ -149,6 +151,13 @@ async function handleVoteClick(choice: Choice) {
:proposal="proposal"
@close="modalOpenTimeline = false"
/>
+
diff --git a/apps/ui/src/components/Ui/Button.vue b/apps/ui/src/components/Ui/Button.vue
index 19d97d0d0..f2f4ccefb 100644
--- a/apps/ui/src/components/Ui/Button.vue
+++ b/apps/ui/src/components/Ui/Button.vue
@@ -27,15 +27,32 @@ withDefaults(
-
diff --git a/apps/ui/src/composables/useActions.ts b/apps/ui/src/composables/useActions.ts
index d1ad1dc75..d7de7410a 100644
--- a/apps/ui/src/composables/useActions.ts
+++ b/apps/ui/src/composables/useActions.ts
@@ -240,7 +240,7 @@ export function useActions() {
);
}
- async function vote(proposal: Proposal, choice: Choice) {
+ async function vote(proposal: Proposal, choice: Choice, reason: string) {
if (!web3.value.account) return await forceLogin();
const network = getNetwork(proposal.network);
@@ -252,7 +252,8 @@ export function useActions() {
web3.value.type as Connector,
web3.value.account,
proposal,
- choice
+ choice,
+ reason
)
);
diff --git a/apps/ui/src/composables/useVotingPower.ts b/apps/ui/src/composables/useVotingPower.ts
new file mode 100644
index 000000000..52a508dc2
--- /dev/null
+++ b/apps/ui/src/composables/useVotingPower.ts
@@ -0,0 +1,68 @@
+import { supportsNullCurrent } from '@/networks';
+import { getIndex as getVotingPowerIndex } from '@/stores/votingPowers';
+import { Proposal, Space } from '@/types';
+
+export function useVotingPower() {
+ const votingPowersStore = useVotingPowersStore();
+ const { web3 } = useWeb3();
+ const { getCurrent } = useMetaStore();
+
+ const item = ref();
+ const block = ref(null);
+
+ const space = computed(() =>
+ item.value && 'space' in item.value
+ ? (item.value?.space as Space)
+ : item.value
+ );
+
+ const proposal = computed(() =>
+ item.value && 'snapshot' in item.value
+ ? (item.value as Proposal)
+ : undefined
+ );
+
+ const proposalSnapshot = computed(() => {
+ if (!proposal.value) return null;
+
+ return proposal.value.state === 'pending' ? null : proposal.value.snapshot;
+ });
+
+ const votingPower = computed(
+ () =>
+ space.value &&
+ votingPowersStore.votingPowers.get(
+ getVotingPowerIndex(space.value, block.value)
+ )
+ );
+
+ function latestBlock(space: Space) {
+ return supportsNullCurrent(space.network)
+ ? null
+ : getCurrent(space.network) ?? 0;
+ }
+
+ function reset() {
+ votingPowersStore.reset();
+ }
+
+ function fetch(spaceOrProposal: Space | Proposal) {
+ if (!web3.value.account) return;
+
+ item.value = spaceOrProposal;
+ block.value = proposal.value
+ ? proposalSnapshot.value
+ : latestBlock(space.value as Space);
+
+ votingPowersStore.fetch(item.value, web3.value.account, block.value);
+ }
+
+ watch(
+ () => web3.value.account,
+ account => {
+ if (!account) reset();
+ }
+ );
+
+ return { votingPower, fetch, reset };
+}
diff --git a/apps/ui/src/helpers/utils.ts b/apps/ui/src/helpers/utils.ts
index dd5313eb6..7dc78149d 100644
--- a/apps/ui/src/helpers/utils.ts
+++ b/apps/ui/src/helpers/utils.ts
@@ -10,7 +10,8 @@ import updateLocale from 'dayjs/plugin/updateLocale';
import sha3 from 'js-sha3';
import { validateAndParseAddress } from 'starknet';
import networks from '@/helpers/networks.json';
-import { Proposal, SpaceMetadata } from '@/types';
+import { VotingPowerItem } from '@/stores/votingPowers';
+import { Choice, Proposal, SpaceMetadata } from '@/types';
import { MAX_SYMBOL_LENGTH } from './constants';
import pkg from '@/../package.json';
import ICCoingecko from '~icons/c/coingecko';
@@ -475,21 +476,24 @@ export function getChoiceWeight(
return isNaN(percent) ? 0 : percent;
}
-export function getChoiceText(
- availableChoices: string[],
- choice: number | number[] | Record
-) {
+export function getChoiceText(availableChoices: string[], choice: Choice) {
+ if (typeof choice === 'string') {
+ return ['for', 'against', 'abstain'].includes(choice)
+ ? choice.charAt(0).toUpperCase() + choice.slice(1)
+ : 'Invalid choice';
+ }
+
if (typeof choice === 'number') {
- return availableChoices[choice - 1];
+ return availableChoices[choice - 1] || 'Invalid choice';
}
if (Array.isArray(choice)) {
- return (
- choice.map(index => availableChoices[index - 1]).join(', ') ||
- 'Blank vote'
- );
+ if (!choice.length) return 'Blank vote';
+ return choice.map(index => availableChoices[index - 1]).join(', ');
}
+ if (!Object.keys(choice).length) return 'Blank vote';
+
const total = Object.values(choice).reduce((acc, weight) => acc + weight, 0);
return Object.entries(choice)
@@ -534,3 +538,12 @@ export function getSocialNetworksLink(data: any) {
})
.filter(social => social.href);
}
+
+export function getFormattedVotingPower(votingPower?: VotingPowerItem) {
+ if (!votingPower) return;
+
+ const { totalVotingPower, decimals, symbol } = votingPower;
+ const value = _vp(Number(totalVotingPower) / 10 ** decimals);
+
+ return symbol ? `${value} ${symbol}` : value;
+}
diff --git a/apps/ui/src/networks/evm/actions.ts b/apps/ui/src/networks/evm/actions.ts
index 150b81e28..c0eca54f7 100644
--- a/apps/ui/src/networks/evm/actions.ts
+++ b/apps/ui/src/networks/evm/actions.ts
@@ -395,7 +395,8 @@ export function createActions(
connectorType: Connector,
account: string,
proposal: Proposal,
- choice: Choice
+ choice: Choice,
+ reason: string
) => {
await verifyNetwork(web3, chainId);
@@ -427,13 +428,16 @@ export function createActions(
})
);
+ let pinned: { cid: string; provider: string } | null = null;
+ if (reason) pinned = await helpers.pin({ reason });
+
const data = {
space: proposal.space.id,
authenticator,
strategies: strategiesWithMetadata,
proposal: proposal.proposal_id as number,
choice: getSdkChoice(choice),
- metadataUri: '',
+ metadataUri: pinned ? `ipfs://${pinned.cid}` : '',
chainId
};
diff --git a/apps/ui/src/networks/offchain/actions.ts b/apps/ui/src/networks/offchain/actions.ts
index 8fe0166df..c1106b80d 100644
--- a/apps/ui/src/networks/offchain/actions.ts
+++ b/apps/ui/src/networks/offchain/actions.ts
@@ -161,7 +161,8 @@ export function createActions(
connectorType: Connector,
account: string,
proposal: Proposal,
- choice: Choice
+ choice: Choice,
+ reason: string
): Promise {
const data = {
space: proposal.space.id,
@@ -171,7 +172,8 @@ export function createActions(
authenticator: '',
strategies: [],
metadataUri: '',
- privacy: proposal.privacy
+ privacy: proposal.privacy,
+ reason
};
return client.vote({
diff --git a/apps/ui/src/networks/starknet/actions.ts b/apps/ui/src/networks/starknet/actions.ts
index 00366be0c..64f31e2e5 100644
--- a/apps/ui/src/networks/starknet/actions.ts
+++ b/apps/ui/src/networks/starknet/actions.ts
@@ -390,7 +390,8 @@ export function createActions(
connectorType: Connector,
account: string,
proposal: Proposal,
- choice: Choice
+ choice: Choice,
+ reason: string
) => {
const isContract = await getIsContract(connectorType, account);
@@ -424,12 +425,16 @@ export function createActions(
})
);
+ let pinned: { cid: string; provider: string } | null = null;
+ if (reason) pinned = await helpers.pin({ reason });
+
const data = {
space: proposal.space.id,
authenticator,
strategies: strategiesWithMetadata,
proposal: proposal.proposal_id as number,
- choice: getSdkChoice(choice)
+ choice: getSdkChoice(choice),
+ metadataUri: pinned ? `ipfs://${pinned.cid}` : ''
};
if (relayerType === 'starknet') {
diff --git a/apps/ui/src/networks/types.ts b/apps/ui/src/networks/types.ts
index e1ba30346..e65f4cbb0 100644
--- a/apps/ui/src/networks/types.ts
+++ b/apps/ui/src/networks/types.ts
@@ -148,7 +148,8 @@ export type ReadOnlyNetworkActions = {
connectorType: Connector,
account: string,
proposal: Proposal,
- choice: Choice
+ choice: Choice,
+ reason: string
): Promise;
followSpace(
web3: Web3Provider | Wallet,
diff --git a/apps/ui/src/stores/votingPowers.ts b/apps/ui/src/stores/votingPowers.ts
new file mode 100644
index 000000000..6360cfc10
--- /dev/null
+++ b/apps/ui/src/stores/votingPowers.ts
@@ -0,0 +1,104 @@
+import { utils } from '@snapshot-labs/sx';
+import { defineStore } from 'pinia';
+import { getNetwork } from '@/networks';
+import { VotingPower, VotingPowerStatus } from '@/networks/types';
+import { Proposal, Space } from '@/types';
+
+const LATEST_BLOCK_NAME = 'latest';
+
+type SpaceDetails = Proposal['space'];
+export type VotingPowerItem = {
+ votingPowers: VotingPower[];
+ totalVotingPower: bigint;
+ status: VotingPowerStatus;
+ symbol: string;
+ decimals: number;
+ error: utils.errors.VotingPowerDetailsError | null;
+ canPropose: boolean;
+ canVote: boolean;
+};
+
+export function getIndex(space: SpaceDetails, block: number | null): string {
+ return `${space.id}:${block ?? LATEST_BLOCK_NAME}`;
+}
+
+export const useVotingPowersStore = defineStore('votingPowers', () => {
+ const votingPowers = reactive
@@ -395,13 +375,14 @@ export default defineComponent({
-
- You do not have enough voting power to create proposal in this space.
-
+ :voting-power="votingPower"
+ action="propose"
+ @fetch-voting-power="handleFetchVotingPower"
+ />
+
-import { utils } from '@snapshot-labs/sx';
-import { getCacheHash, getStampUrl, sanitizeUrl } from '@/helpers/utils';
-import { getNetwork, offchainNetworks } from '@/networks';
-import { VotingPower, VotingPowerStatus } from '@/networks/types';
+import {
+ getCacheHash,
+ getFormattedVotingPower,
+ getStampUrl,
+ sanitizeUrl
+} from '@/helpers/utils';
+import { offchainNetworks } from '@/networks';
import { Choice } from '@/types';
const route = useRoute();
+const proposalsStore = useProposalsStore();
+const {
+ votingPower,
+ fetch: fetchVotingPower,
+ reset: resetVotingPower
+} = useVotingPower();
const { setFavicon } = useFavicon();
const { param } = useRouteParser('space');
const { resolved, address: spaceAddress, networkId } = useResolve(param);
const { setTitle } = useTitle();
-const proposalsStore = useProposalsStore();
const { web3 } = useWeb3();
-const { loadVotes, votes } = useAccount();
-const { vote } = useActions();
+const { modalAccountOpen } = useModal();
-const sendingType = ref(null);
-const votingPowers = ref([] as VotingPower[]);
-const votingPowerStatus = ref('loading');
-const votingPowerDetailsError =
- ref(null);
+const modalOpenVote = ref(false);
+const selectedChoice = ref(null);
+const { loadVotes, votes } = useAccount();
const editMode = ref(false);
-const network = computed(() =>
- networkId.value ? getNetwork(networkId.value) : null
-);
const id = computed(() => route.params.id as string);
const proposal = computed(() => {
if (!resolved.value || !spaceAddress.value || !networkId.value) {
@@ -60,50 +62,22 @@ const currentVote = computed(
votes.value[`${proposal.value.network}:${proposal.value.id}`]
);
-async function getVotingPower() {
- if (!network.value) return;
-
- votingPowerDetailsError.value = null;
-
- if (!web3.value.account || !proposal.value) {
- votingPowers.value = [];
- votingPowerStatus.value = 'success';
+async function handleVoteClick(choice: Choice) {
+ if (!web3.value.account) {
+ modalAccountOpen.value = true;
return;
}
- votingPowerStatus.value = 'loading';
- try {
- votingPowers.value = await network.value.actions.getVotingPower(
- proposal.value.space.id,
- proposal.value.strategies,
- proposal.value.strategies_params,
- proposal.value.space.strategies_parsed_metadata,
- web3.value.account,
- {
- at: proposal.value.state === 'pending' ? null : proposal.value.snapshot,
- chainId: proposal.value.space.snapshot_chain_id
- }
- );
- votingPowerStatus.value = 'success';
- } catch (e: unknown) {
- if (e instanceof utils.errors.VotingPowerDetailsError) {
- votingPowerDetailsError.value = e;
- } else {
- console.warn('Failed to load voting power', e);
- }
-
- votingPowers.value = [];
- votingPowerStatus.value = 'error';
- }
+ selectedChoice.value = choice;
+ modalOpenVote.value = true;
}
-async function handleVoteClick(choice: Choice) {
+async function handleVoteSubmitted() {
if (!proposal.value) return;
- sendingType.value = choice;
+ selectedChoice.value = null;
try {
- await vote(proposal.value, choice);
// TODO: Quick fix only for offchain proposals, need a more complete solution for onchain proposals
if (offchainNetworks.includes(proposal.value.network)) {
proposalsStore.fetchProposal(
@@ -114,12 +88,30 @@ async function handleVoteClick(choice: Choice) {
await loadVotes(proposal.value.network, [proposal.value.space.id]);
}
} finally {
- sendingType.value = null;
editMode.value = false;
}
}
-watch([() => web3.value.account, proposal], () => getVotingPower());
+function handleFetchVotingPower() {
+ if (!proposal.value) return;
+
+ fetchVotingPower(proposal.value);
+}
+
+watch(
+ [proposal, () => web3.value.account, () => web3.value.authLoading],
+ ([toProposal, toAccount, toAuthLoading], [, fromAccount]) => {
+ if (fromAccount && toAccount && fromAccount !== toAccount) {
+ resetVotingPower();
+ }
+
+ if (toAuthLoading || !toProposal || !toAccount) return;
+
+ handleFetchVotingPower();
+ },
+ { immediate: true }
+);
+
watch(
[networkId, spaceAddress, id],
async ([networkId, spaceAddress, id]) => {
@@ -223,17 +215,16 @@ watchEffect(() => {
v-if="web3.account && networkId && (!currentVote || editMode)"
v-slot="props"
:network-id="networkId"
- :status="votingPowerStatus"
- :voting-power-symbol="proposal.space.voting_power_symbol"
- :voting-powers="votingPowers"
+ :voting-power="votingPower"
class="mb-2 flex items-center"
- @get-voting-power="getVotingPower"
+ @fetch-voting-power="handleFetchVotingPower"
>
+
+
+
diff --git a/apps/ui/src/views/Space/Proposals.vue b/apps/ui/src/views/Space/Proposals.vue
index ea1be31f8..e10db8769 100644
--- a/apps/ui/src/views/Space/Proposals.vue
+++ b/apps/ui/src/views/Space/Proposals.vue
@@ -1,22 +1,19 @@