diff --git a/client/scripts/app.ts b/client/scripts/app.ts index 9b153196ad5..8472b62e4f7 100644 --- a/client/scripts/app.ts +++ b/client/scripts/app.ts @@ -320,6 +320,13 @@ export async function selectNode(n?: NodeInfo, deferred = false): Promise { '/:scope/discussions/:topic': importRoute('views/pages/discussions', { scoped: true, deferChain: true }), '/:scope/search': importRoute('views/pages/search', { scoped: true, deferChain: true }), '/:scope/members': importRoute('views/pages/members', { scoped: true, deferChain: true }), - '/:scope/snapshot-proposals': importRoute('views/pages/snapshot_proposals', { scoped: true, deferChain: true }), - '/:scope/snapshot-proposal/:identifier': importRoute('views/pages/view_snapshot_proposal/index', { scoped: true }), + '/:scope/snapshot-proposals/:snapshotId': importRoute('views/pages/snapshot_proposals', { scoped: true, deferChain: true }), + '/:scope/snapshot-proposal/:snapshotId/:identifier': importRoute('views/pages/view_snapshot_proposal', { scoped: true }), '/:scope/chat': importRoute('views/pages/chat', { scoped: true, deferChain: true }), '/:scope/referenda': importRoute('views/pages/referenda', { scoped: true }), '/:scope/proposals': importRoute('views/pages/proposals', { scoped: true }), @@ -639,6 +646,7 @@ $(() => { '/:scope/delegate': importRoute('views/pages/delegate', { scoped: true, }), '/:scope/login': importRoute('views/pages/login', { scoped: true, deferChain: true }), '/:scope/new/thread': importRoute('views/pages/new_thread', { scoped: true, deferChain: true }), + '/:scope/new/snapshot-proposal/:snapshotId': importRoute('views/pages/new_snapshot_proposal', { scoped: true, deferChain: true }), '/:scope/new/proposal/:type': importRoute('views/pages/new_proposal/index', { scoped: true }), '/:scope/admin': importRoute('views/pages/admin', { scoped: true }), '/:scope/spec_settings': importRoute('views/pages/spec_settings', { scoped: true, deferChain: true }), diff --git a/client/scripts/controllers/chain/ethereum/sushi/adapter.ts b/client/scripts/controllers/chain/ethereum/sushi/adapter.ts new file mode 100644 index 00000000000..e7da7ba2bb5 --- /dev/null +++ b/client/scripts/controllers/chain/ethereum/sushi/adapter.ts @@ -0,0 +1,63 @@ +import { EthereumCoin } from 'adapters/chain/ethereum/types'; + +import { Erc20Factory } from 'Erc20Factory'; +import EthereumAccount from 'controllers/chain/ethereum/account'; +import EthereumAccounts from 'controllers/chain/ethereum/accounts'; +import { ChainBase, IChainAdapter, NodeInfo } from 'models'; + +import ChainEntityController from 'controllers/server/chain_entities'; +import { IApp } from 'state'; + +import EthereumTokenChain from './chain'; +import SushiApi from './api'; + +export default class Sushi extends IChainAdapter { + public readonly base = ChainBase.Ethereum; + // TODO: ensure this chainnetwork -> chainclass + public readonly class; + public readonly contractAddress: string; + public readonly isToken = true; + + public chain: EthereumTokenChain; + public accounts: EthereumAccounts; + public hasToken: boolean = false; + + constructor(meta: NodeInfo, app: IApp) { + super(meta, app); + this.chain = new EthereumTokenChain(this.app); + this.accounts = new EthereumAccounts(this.app); + this.class = meta.chain.network; + this.contractAddress = meta.address; + } + + public async initApi() { + await this.chain.resetApi(this.meta); + await this.chain.initMetadata(); + await this.accounts.init(this.chain); + const api = new SushiApi(Erc20Factory.connect, this.meta.address, this.chain.api.currentProvider as any); + await api.init(); + this.chain.contractApi = api; + await super.initApi(); + } + + public async initData() { + await this.chain.initEventLoop(); + await super.initData(); + await this.activeAddressHasToken(this.app.user?.activeAccount?.address); + } + + public async deinit() { + await super.deinit(); + this.accounts.deinit(); + this.chain.deinitMetadata(); + this.chain.deinitEventLoop(); + this.chain.deinitApi(); + } + + public async activeAddressHasToken(activeAddress?: string) { + if (!activeAddress) return false; + const account = this.accounts.get(activeAddress); + const balance = await account.tokenBalance(this.contractAddress); + this.hasToken = balance && !balance.isZero(); + } +} diff --git a/client/scripts/controllers/chain/ethereum/sushi/api.ts b/client/scripts/controllers/chain/ethereum/sushi/api.ts new file mode 100644 index 00000000000..a3a297fb896 --- /dev/null +++ b/client/scripts/controllers/chain/ethereum/sushi/api.ts @@ -0,0 +1,5 @@ +import { Erc20 } from 'Erc20'; + +import ContractApi from 'controllers/chain/ethereum/contractApi'; + +export default class SushiApi extends ContractApi { } diff --git a/client/scripts/controllers/chain/ethereum/sushi/chain.ts b/client/scripts/controllers/chain/ethereum/sushi/chain.ts new file mode 100644 index 00000000000..8f32b639579 --- /dev/null +++ b/client/scripts/controllers/chain/ethereum/sushi/chain.ts @@ -0,0 +1,8 @@ +import EthereumChain from '../chain'; +import ContractApi from './api'; + +// Thin wrapper over EthereumChain to guarantee the `init()` implementation +// on the Governance module works as expected. +export default class SushiChain extends EthereumChain { + public contractApi: ContractApi; +} diff --git a/client/scripts/controllers/server/snapshot.ts b/client/scripts/controllers/server/snapshot.ts index 8d5326e00b6..35f51c1a859 100644 --- a/client/scripts/controllers/server/snapshot.ts +++ b/client/scripts/controllers/server/snapshot.ts @@ -10,7 +10,8 @@ class SnapshotController { public get proposalStore() { return this._proposalStore; } public async fetchSnapshotProposals(snapshot: string) { - const response = await $.get(`https://hub.snapshot.page/api/${snapshot}/proposals`); + const hubUrl = process.env.SNAPSHOT_APP_HUB_URL || 'https://testnet.snapshot.org'; + const response = await $.get(`${hubUrl}/api/${snapshot}/proposals`); // if (response.status !== 'Success') { // throw new Error(`Cannot fetch snapshot proposals: ${response.status}`); // } diff --git a/client/scripts/helpers/index.ts b/client/scripts/helpers/index.ts index f3927c33986..dd4e07c74c1 100644 --- a/client/scripts/helpers/index.ts +++ b/client/scripts/helpers/index.ts @@ -308,6 +308,16 @@ export const loadScript = (scriptURI) => { }); }; +export function formatSpace(key, space) { + space = { + key, + ...space, + members: space.members || [], + filters: space.filters || {} + }; + if (!space.filters.minScore) space.filters.minScore = 0; + return space; +} export const removeOrAddClasslistToAllElements = ( cardList: ICardListItem[], diff --git a/client/scripts/helpers/snapshot_client.ts b/client/scripts/helpers/snapshot_client.ts new file mode 100644 index 00000000000..7623fc9ba11 --- /dev/null +++ b/client/scripts/helpers/snapshot_client.ts @@ -0,0 +1,6 @@ +import Client from '@snapshot-labs/snapshot.js/src/client'; + +const hubUrl = process.env.SNAPSHOT_APP_HUB_URL || 'https://testnet.snapshot.org'; +const snapshotClient = new Client(hubUrl); + +export default snapshotClient; diff --git a/client/scripts/models/ChainInfo.ts b/client/scripts/models/ChainInfo.ts index d694e7b1375..a54106b1308 100644 --- a/client/scripts/models/ChainInfo.ts +++ b/client/scripts/models/ChainInfo.ts @@ -175,7 +175,7 @@ class ChainInfo { // TODO: change to accept an object public async updateChainData({ name, description, website, discord, element, telegram, - github, stagesEnabled, additionalStages, customDomain + github, stagesEnabled, additionalStages, customDomain, snapshot }) { // TODO: Change to PUT /chain const r = await $.post(`${app.serverUrl()}/updateChain`, { @@ -190,6 +190,7 @@ class ChainInfo { 'stagesEnabled': stagesEnabled, 'additionalStages': additionalStages, 'customDomain': customDomain, + 'snapshot': snapshot, 'jwt': app.user.jwt, }); const updatedChain: ChainInfo = r.result; @@ -203,6 +204,7 @@ class ChainInfo { this.stagesEnabled = updatedChain.stagesEnabled; this.additionalStages = updatedChain.additionalStages; this.customDomain = updatedChain.customDomain; + this.snapshot = updatedChain.snapshot; } public addFeaturedTopic(topic: string) { diff --git a/client/scripts/models/types.ts b/client/scripts/models/types.ts index c3afab4cea5..4fa73bbbb5d 100644 --- a/client/scripts/models/types.ts +++ b/client/scripts/models/types.ts @@ -44,6 +44,7 @@ export enum ChainNetwork { CosmosHub = 'cosmos-hub', Gaia13k = 'gaia-13k', Yearn = 'yearn', + Sushi = 'sushi', Fei = 'fei' } @@ -126,6 +127,12 @@ export enum ChainClass { Commonwealth = 'commonwealth', Yearn = 'yearn', Fei = 'fei', +<<<<<<< HEAD +======= + Sushi = 'sushi', + Crust = 'crust', + ERC20 = 'erc20', +>>>>>>> Add Snapshot Creation (#1216) } >>>>>>> Add yearn and fei migrations and controllers (#1156) diff --git a/client/scripts/views/components/sidebar/index.ts b/client/scripts/views/components/sidebar/index.ts index f394dc4b5b1..4ff4bf690ce 100644 --- a/client/scripts/views/components/sidebar/index.ts +++ b/client/scripts/views/components/sidebar/index.ts @@ -177,6 +177,10 @@ export const OnchainNavigationModule: m.Component<{}, {}> = { const showCommonwealthMenuOptions = app.chain?.network === ChainNetwork.Commonwealth; const showMarlinOptions = app.user.activeAccount && app.chain?.network === ChainNetwork.Marlin; + const showSubmitSnapshotProposalOptions = app.user.activeAccount && app.chain?.meta.chain.snapshot && + (app.chain?.network === ChainNetwork.Yearn || + app.chain?.network === ChainNetwork.Fei || + app.chain?.network === ChainNetwork.Sushi); const onSnapshotProposal = (p) => p.startsWith(`/${app.activeId()}/snapshot-proposals`); const onProposalPage = (p) => ( @@ -388,9 +392,18 @@ export const OnchainNavigationModule: m.Component<{}, {}> = { label: 'Snapshot Proposals', onclick: (e) => { e.preventDefault(); - m.route.set(`/${app.activeId()}/snapshot-proposals`); + m.route.set(`/${app.activeChainId()}/snapshot-proposals/${app.chain.meta.chain.snapshot}`); }, }), + showSubmitSnapshotProposalOptions && m(Button, { + fluid: true, + rounded: true, + onclick: (e) => { + e.preventDefault(); + m.route.set(`/${app.activeChainId()}/new/snapshot-proposal/${app.chain.meta.chain.snapshot}`); + }, + label: 'Submit a Proposal', + }), showCommonwealthMenuOptions && m(Button, { fluid: true, rounded: true, diff --git a/client/scripts/views/modals/manage_community_modal/chain_metadata_management_table.ts b/client/scripts/views/modals/manage_community_modal/chain_metadata_management_table.ts index bbe6acab488..bafee546a84 100644 --- a/client/scripts/views/modals/manage_community_modal/chain_metadata_management_table.ts +++ b/client/scripts/views/modals/manage_community_modal/chain_metadata_management_table.ts @@ -24,6 +24,7 @@ interface IChainMetadataManagementState { customDomain: string; network: ChainNetwork; symbol: string; + snapshot: string; } const ChainMetadataManagementTable: m.Component = { @@ -41,6 +42,7 @@ const ChainMetadataManagementTable: m.Component { return m('.ChainMetadataManagementTable', [ @@ -111,6 +113,12 @@ const ChainMetadataManagementTable: m.Component { vnode.state.customDomain = v; }, }), + m(InputPropertyRow, { + title: 'Snapshot', + defaultValue: vnode.state.snapshot, + placeholder: vnode.state.network, + onChangeHandler: (v) => { vnode.state.snapshot = v; }, + }), m('tr', [ m('td', 'Admins'), m('td', [ m(ManageRolesRow, { @@ -142,7 +150,8 @@ const ChainMetadataManagementTable: m.Component = { + view: (vnode) => { + + return m(Sublayout, { + class: 'NewProposalPage', + title: `New Snapshot Proposal`, + showNewProposalButton: true, + }, [ + m('.forum-container', [ + m(NewProposalForm, {snapshotId: vnode.attrs.snapshotId}), + ]) + ]); + } +}; + +export default NewSnapshotProposalPage; diff --git a/client/scripts/views/pages/new_snapshot_proposal/new_proposal_form.ts b/client/scripts/views/pages/new_snapshot_proposal/new_proposal_form.ts new file mode 100644 index 00000000000..3c7725272c7 --- /dev/null +++ b/client/scripts/views/pages/new_snapshot_proposal/new_proposal_form.ts @@ -0,0 +1,352 @@ +import 'pages/new_proposal_page.scss'; +import 'mithril-datepicker/src/style.css'; +import 'mithril-timepicker/src/style.css'; + +import $ from 'jquery'; +import m from 'mithril'; +import mixpanel from 'mixpanel-browser'; +import { Input, Form, FormLabel, FormGroup, Button, Callout } from 'construct-ui'; +import DatePicker from 'mithril-datepicker'; +// import TimePicker from 'mithril-timepicker'; +import moment from 'moment'; +import { bufferToHex } from 'ethereumjs-util'; +import getProvider from '@snapshot-labs/snapshot.js/src/utils/provider'; +import { getBlockNumber, signMessage } from '@snapshot-labs/snapshot.js/src/utils/web3'; +import { version } from '@snapshot-labs/snapshot.js/src/constants.json'; +// import { getScores } from '@snapshot-labs/snapshot.js/src/utils'; + +import app from 'state'; +import snapshotClient from 'helpers/snapshot_client'; +import { formatSpace } from 'helpers'; + +import { notifyError } from 'controllers/app/notifications'; +import QuillEditor from 'views/components/quill_editor'; + + +declare global { + interface ObjectConstructor { + fromEntries(xs: [string|number|symbol, any][]): object + } +} + +const fromEntries = (xs: [string|number|symbol, any][]) => + Object.fromEntries ? Object.fromEntries(xs) : xs.reduce((acc, [key, value]) => ({...acc, [key]: value}), {}) + +DatePicker.localize({ + weekStart: 1, + locale: 'en', + prevNextTitles: ['1M', '1Y', '10Y'], + formatOptions: { + weekday: 'short', + day: 'numeric', + month: 'short', + year: 'numeric' + } +}) + +interface IThreadForm { + name: string; + body: string; + choices: string[]; + start: number; + end: number; + snapshot: number, + metadata: {}, + type: string +} + +enum NewThreadErrors { + NoBody = 'Proposal body cannot be blank!', + NoTitle = 'Title cannot be blank!', + NoChoices = 'Choices cannot be blank!', + NoStartDate = 'Start Date cannot be blank!', + NoEndDate = 'End Date cannot be blank!', + SomethingWentWrong = "Something went wrong!" +} + +const newThread = async ( + form, + quillEditorState, + author, + space, + snapshotId +) => { + const topics = app.chain + ? app.chain.meta.chain.topics + : app.community.meta.topics; + + if (!form.name) { + throw new Error(NewThreadErrors.NoTitle); + } + + if (!form.start) { + throw new Error(NewThreadErrors.NoStartDate); + } + + if (!form.end) { + throw new Error(NewThreadErrors.NoEndDate); + } + + if (!form.choices[0] || !form.choices[1]) { + throw new Error(NewThreadErrors.NoChoices); + } + + if (quillEditorState.editor.editor.isBlank()) { + throw new Error(NewThreadErrors.NoBody); + } + + quillEditorState.editor.enable(false); + + const mentionsEle = document.getElementsByClassName('ql-mention-list-container')[0]; + if (mentionsEle) (mentionsEle as HTMLElement).style.visibility = 'hidden'; + const bodyText = !quillEditorState ? '' + : quillEditorState.markdownMode + ? quillEditorState.editor.getText() + : JSON.stringify(quillEditorState.editor.getContents()); + + form.body = bodyText; + form.snapshot = await getBlockNumber(getProvider(space.network)); + form.metadata.network = space.network; + form.metadata.strategies = space.strategies; + + const msg: any = { + address: author.address, + msg: JSON.stringify({ + version, + timestamp: (Date.now() / 1e3).toFixed(), + space: space.key, + type: 'proposal', + payload: form + }) + }; + + const msgBuffer = bufferToHex(new Buffer(msg.msg, 'utf8')); + msg.sig = await (window as any).ethereum.request({method: 'personal_sign', params: [msgBuffer, author.address]}); + + let result = await $.post(`${app.serverUrl()}/snapshotAPI/sendMessage`, { ...msg }); + + if (result.status === "Failure") { + mixpanel.track('Create Snapshot Proposal', { + 'Step No': 2, + 'Step' : 'Incorrect Proposal', + }); + + const errorMessage = + result && result.message.error_description + ? `${result.message.error_description}` + : NewThreadErrors.SomethingWentWrong; + throw new Error(errorMessage); + } else if (result.status === "Success") { + await app.user.notifications.refresh(); + + m.route.set(`/${app.activeId()}/snapshot-proposal/${snapshotId}/${result.message.ipfsHash}`); + + mixpanel.track('Create Snapshot Proposal', { + 'Step No': 2, + Step: 'Filled in Snapshot Proposal', + }); + } +}; + +const newLink = async (form, quillEditorState, author, space, snapshotId) => { + const errors = await newThread(form, quillEditorState, author, space, snapshotId); + return errors; +}; + +export const NewProposalForm: m.Component<{snapshotId: string}, { + form: IThreadForm, + quillEditorState, + saving: boolean, + space: any, + members: string[] +}> = { + oninit: (vnode) => { + vnode.state.space = {}; + vnode.state.members = []; + vnode.state.form = { + name: '', + body: '', + choices: ['Yes', 'No'], + start: 0, + end: 0, + snapshot: 0, + metadata: {}, + type: 'single-choice' + }; + + snapshotClient.getSpaces().then(response => { + let spaces: any = fromEntries( + Object.entries(response).map(space => [ + space[0], + formatSpace(space[0], space[1]) + ]) + ); + console.log(spaces); + let space = spaces[vnode.attrs.snapshotId]; + console.log(space); + vnode.state.space = space; + vnode.state.members = space.members; + m.redraw(); + + // getScores( + // space.key, + // space.strategies, + // space.network, + // getProvider(space.network), + // [app.user.activeAccount.address] + // ).then(response => { + // console.log(response) + // let scores = response + // .map(score => Object.values(score).reduce((a, b) => (a as number) + (b as number), 0)) + // .reduce((a, b) => (a as number) + (b as number), 0); + // vnode.state.userScore = scores as number; + // vnode.state.space = space; + // m.redraw(); + // }); + }); + }, + + view: (vnode) => { + if (!app.community && !app.chain) return; + const author = app.user.activeAccount; + const activeEntityInfo = app.community ? app.community.meta : app.chain.meta.chain; + if (vnode.state.quillEditorState?.container) { + vnode.state.quillEditorState.container.tabIndex = 8; + } + + const saveToLocalStorage = () => { + localStorage.setItem(`${app.activeId()}-new-snapshot-proposal-name`, vnode.state.form.name); + }; + + const populateFromLocalStorage = () => { + vnode.state.form.name = localStorage.getItem(`${app.activeId()}-new-snapshot-proposal-name`); + }; + + const clearLocalStorage = () => { + localStorage.removeItem(`${app.activeId()}-new-snapshot-proposal-name`); + }; + + const isMember = author && author.address && vnode.state.members.includes(author.address.toLowerCase()); + + let isValid = vnode.state.space !== undefined && + (!vnode.state.space.filters?.onlyMembers || + (vnode.state.space.filters?.onlyMembers && isMember)); + // (vnode.state.space.filters?.minScore === 0 || + // (vnode.state.space.filters?.minScore > 0 && vnode.state.userScore) || + // isMember); + + return m('.NewThreadForm', { + oncreate: (vvnode) => { + $(vvnode.dom).find('.cui-input input').prop('autocomplete', 'off').focus(); + }, + }, [ + m('.new-thread-form-body', [ + vnode.state.space.filters?.onlyMembers && !isMember && + m(Callout, { + class: 'no-profile-callout', + intent: 'primary', + content: [ + 'You need to be a member of the space in order to submit a proposal.', + ], + }), + m('.new-snapshot-proposal-form', [ + m(Form, [ + m(FormGroup, { span: { xs: 12, sm: 12 }, order: 2 }, [ + m(Input, { + placeholder: 'Question', + oninput: (e) => { + e.redraw = false; // do not redraw on input + const { value } = e.target as any; + vnode.state.form.name = value; + localStorage.setItem(`${app.activeId()}-new-snapshot-proposal-name`, vnode.state.form.name); + }, + defaultValue: vnode.state.form.name, + tabindex: 1, + }), + ]), + m(FormGroup, { order: 4 }, [ + m(QuillEditor, { + contentsDoc: '', // Prevent the editor from being filled in with previous content + oncreateBind: (state) => { + vnode.state.quillEditorState = state; + }, + placeholder: 'What is your proposal', + editorNamespace: 'new-proposal', + tabindex: 2, + }) + ]), + m(FormGroup, { order: 5 }, [ + m(Button, { + intent: 'primary', + label: 'Publish', + name: 'submit', + disabled: !author || vnode.state.saving || !isValid, + rounded: true, + onclick: async (e) => { + vnode.state.saving = true; + try { + await newLink(vnode.state.form, vnode.state.quillEditorState, author, vnode.state.space, vnode.attrs.snapshotId); + vnode.state.saving = false; + clearLocalStorage(); + } catch (err) { + vnode.state.saving = false; + notifyError(err.message); + } + }, + }), + ]), + ]), + m(Form, [ + m('h4', 'Choices'), + m(FormGroup, [ + m(FormLabel, 'Choice 1'), + m(Input, { + name: 'targets', + placeholder: 'Yes', + oninput: (e) => { + const result = (e.target as any).value; + vnode.state.form.choices[0] = result; + m.redraw(); + }, + }), + ]), + m(FormGroup, [ + m(FormLabel, 'Choice 2'), + m(Input, { + name: 'targets', + placeholder: 'No', + oninput: (e) => { + const result = (e.target as any).value; + vnode.state.form.choices[1] = result; + m.redraw(); + }, + }), + ]), + m('h4', 'Start Date'), + m(DatePicker, + { + locale: 'en-us', + weekStart: 0, + onchange: function(chosenDate){ + vnode.state.form.start = moment(chosenDate).unix(); + } + } + ), + m('h4', 'End Date'), + m(DatePicker, + { + locale: 'en-us', + weekStart: 0, + onchange: function(chosenDate){ + vnode.state.form.end = moment(chosenDate).unix(); + } + } + ), + ]), + ]), + ]), + ]); + } +}; + +export default NewProposalForm; diff --git a/client/scripts/views/pages/snapshot_proposals/index.ts b/client/scripts/views/pages/snapshot_proposals/index.ts index 6e087e916d4..1d3947a7f69 100644 --- a/client/scripts/views/pages/snapshot_proposals/index.ts +++ b/client/scripts/views/pages/snapshot_proposals/index.ts @@ -1,6 +1,5 @@ import 'pages/discussions/index.scss'; -import $ from 'jquery'; import _ from 'lodash'; import m from 'mithril'; import mixpanel from 'mixpanel-browser'; @@ -8,36 +7,14 @@ import moment from 'moment-twitter'; import app from 'state'; import { Spinner, Button, ButtonGroup, Icons, Icon, PopoverMenu, MenuItem } from 'construct-ui'; -import { pluralize, offchainThreadStageToLabel, externalLink } from 'helpers'; -import { NodeInfo, CommunityInfo, OffchainThreadStage } from 'models'; -import { updateLastVisited } from 'controllers/app/login'; -import { notifyError } from 'controllers/app/notifications'; import Sublayout from 'views/sublayout'; -import PageLoading from 'views/pages/loading'; -import EmptyTopicPlaceholder, { EmptyStagePlaceholder } from 'views/components/empty_topic_placeholder'; -import LoadingRow from 'views/components/loading_row'; import Listing from 'views/pages/listing'; -import NewTopicModal from 'views/modals/new_topic_modal'; -import EditTopicModal from 'views/modals/edit_topic_modal'; -import CreateInviteModal from 'views/modals/create_invite_modal'; -import { INITIAL_PAGE_SIZE } from 'controllers/server/threads'; -// import PinnedListing from './pinned_listing'; import ProposalRow from './proposal_row'; export const ALL_PROPOSALS_KEY = 'COMMONWEALTH_ALL_PROPOSALS'; -const getLastSeenDivider = (hasText = true) => { - return m('.LastSeenDivider', hasText ? [ - m('hr'), - m('span', 'Last visit'), - m('hr'), - ] : [ - m('hr'), - ]); -}; - const SnapshotProposalStagesBar: m.Component<{}, {}> = { view: (vnode) => { return m('.DiscussionStagesBar.discussions-stages', [ @@ -97,7 +74,7 @@ const SnapshotProposalStagesBar: m.Component<{}, {}> = { } }; -const SnapshotProposalsPage: m.Component<{ topic?: string }, { +const SnapshotProposalsPage: m.Component<{ topic?: string, snapshotId: string }, { lookback?: { [community: string]: moment.Moment} ; postsDepleted: { [community: string]: boolean }; topicInitialized: { [community: string]: boolean }; @@ -105,24 +82,30 @@ const SnapshotProposalsPage: m.Component<{ topic?: string }, { lastVisitedUpdated?: boolean; onscroll: any; allProposals: any; + listing: any[] }> = { oncreate: (vnode) => { mixpanel.track('PageVisit', { 'Page Name': 'Snapshot Proposals Page', Scope: app.activeId(), }); + const snapshotId = vnode.attrs.snapshotId + app.snapshot.fetchSnapshotProposals(snapshotId).then(response => { + vnode.state.listing = app.snapshot.proposalStore.getAll() + .map((proposal) => m(ProposalRow, { snapshotId, proposal })) + + m.redraw(); + }); }, oninit: (vnode) => { + vnode.state.listing = []; }, view: (vnode) => { let listing = []; + listing.push(m('.discussion-group-wrap', vnode.state.listing)); - listing.push(m('.discussion-group-wrap', app.snapshot.proposalStore.getAll() - .map((proposal) => m(ProposalRow, { proposal })))); - - return m(Sublayout, { class: 'DiscussionsPage', title: 'Snapshot Proposals', diff --git a/client/scripts/views/pages/snapshot_proposals/proposal_row.ts b/client/scripts/views/pages/snapshot_proposals/proposal_row.ts index e6d067c287b..88c15036f60 100644 --- a/client/scripts/views/pages/snapshot_proposals/proposal_row.ts +++ b/client/scripts/views/pages/snapshot_proposals/proposal_row.ts @@ -10,12 +10,12 @@ import { formatLastUpdated, link } from 'helpers'; import { SnapshotProposal } from 'models'; import ProposalListingRow from 'views/components/proposal_listing_row'; -const ProposalRow: m.Component<{ proposal: SnapshotProposal }, { expanded: boolean }> = { +const ProposalRow: m.Component<{ snapshotId: string, proposal: SnapshotProposal }, { expanded: boolean }> = { view: (vnode) => { const { proposal } = vnode.attrs; if (!proposal) return; - const proposalLink = `/${app.activeId()}/snapshot-proposal/${proposal.ipfsHash}`; + const proposalLink = `/${app.activeId()}/snapshot-proposal/${vnode.attrs.snapshotId}/${proposal.ipfsHash}`; const time = moment(+proposal.end * 1000); const now = moment(); @@ -26,7 +26,7 @@ const ProposalRow: m.Component<{ proposal: SnapshotProposal }, { expanded: boole proposal.ipfsHash && link('a.proposal-topic', proposalLink, [ m('span.proposal-topic-name', `${proposal.ipfsHash}`), ]), - m('.created-at', link('a', proposalLink, (now > time) ? `Ended ${formatLastUpdated(time)}` + m('.created-at m-l-20', link('a', proposalLink, (now > time) ? `Ended ${formatLastUpdated(time)}` : `Ending ${formatLastUpdated(moment(+proposal.end * 1000))}`)), ]; diff --git a/client/scripts/views/pages/view_snapshot_proposal/index.ts b/client/scripts/views/pages/view_snapshot_proposal/index.ts index c81cd84cc95..be09b7d9360 100644 --- a/client/scripts/views/pages/view_snapshot_proposal/index.ts +++ b/client/scripts/views/pages/view_snapshot_proposal/index.ts @@ -1,8 +1,11 @@ import 'pages/view_proposal/index.scss'; +import 'components/proposals/voting_results.scss'; +import 'components/proposals/voting_actions.scss'; import $ from 'jquery'; import m from 'mithril'; import mixpanel from 'mixpanel-browser'; +import { Spinner, Button } from 'construct-ui'; import app from 'state'; import Sublayout from 'views/sublayout'; @@ -15,15 +18,19 @@ import { } from './body'; const ProposalHeader: m.Component<{ + snapshotId: string proposal: SnapshotProposal }, {}> = { view: (vnode) => { const { proposal } = vnode.attrs; + if (!proposal) { + return m('.topic-loading-spinner-wrap', [ m(Spinner, { active: true, size: 'lg' }) ]) + } // Original posters have full editorial control, while added collaborators // merely have access to the body and title - const proposalLink = `/${app.activeId()}/snapshot-proposal/${proposal.ipfsHash}`; + const proposalLink = `/${app.activeId()}/snapshot-proposal/${vnode.attrs.snapshotId}/${proposal.ipfsHash}`; return m('.ProposalHeader', { class: `proposal-snapshot` @@ -61,69 +68,150 @@ const VoteRow: m.Component<{ view: (vnode) => { return m('.ViewRow', [ m('.row-left', vnode.attrs.vote.voterAddress), - m('.row-right', vnode.attrs.vote.choice) + // m('.row-right', vnode.attrs.vote.choice) ]); } } -// Should complete it after mocking up. -const VoteView: m.Component<{ - votes: Vote[], - voteCount: number -}, {}> = { +const VoteView: m.Component<{ votes: Vote[] }> = { view: (vnode) => { - const { votes, voteCount } = vnode.attrs; - let voteListing = []; + const { votes } = vnode.attrs; + + let voteYesListing = []; + let voteNoListing = []; - voteListing.push(m('.vote-group-wrap', votes - .map((vote) => m(VoteRow, { vote })))); + voteYesListing.push(m('.vote-group-wrap', votes + .map((vote) => { + if (vote.choice === 'yes'){ + return m(VoteRow, { vote }); + } + }) + )); + + voteNoListing.push(m('.vote-group-wrap', votes + .map((vote) => { + if (vote.choice === 'no'){ + return m(VoteRow, { vote }); + } + }) + )); - return m('.VoteView', [ - m('h1', [ - 'Votes', - m('.vote-count', `${voteCount}`) + // TODO: fix up this function for cosmos votes + return m('.VotingResults', [ + m('.results-column', [ + m('.results-header', `Voted yes (${votes.filter((v) => v.choice === 'yes').length})`), + m('.results-cell', [ + voteYesListing + ]), ]), - voteListing + m('.results-column', [ + m('.results-header', `Voted no (${votes.filter((v) => v.choice === 'no').length})`), + m('.results-cell', [ + voteNoListing + ]), + ]) ]); } +}; + +const VoteAction: m.Component<{ + choices: string[], +}, { + votingModalOpen: boolean +}> = { + view: (vnode) => { + let { choices } = vnode.attrs; + let canVote = true; + let hasVotedYes = false; + let hasVotedNo = false; + const { votingModalOpen } = vnode.state; + + const onModalClose = () => { + vnode.state.votingModalOpen = false; + m.redraw(); + }; + + const voteYes = async (e) => { + e.preventDefault(); + vnode.state.votingModalOpen = false; + }; + + const voteNo = (e) => { + e.preventDefault(); + vnode.state.votingModalOpen = false; + // open modal and check canVote and create vote + }; + + const yesButton = m('.yes-button', [ + m(Button, { + intent: 'positive', + disabled: !canVote || hasVotedYes || votingModalOpen, + onclick: voteYes, + label: hasVotedYes ? `Voted "${choices[0]}"` : `Vote "${choices[0]}"`, + compact: true, + rounded: true, + }), + ]); + const noButton = m('.no-button', [ + m(Button, { + intent: 'negative', + disabled: !canVote || hasVotedNo || votingModalOpen, + onclick: voteNo, + label: hasVotedNo ? `Voted "${choices[1]}"` : `Vote "${choices[1]}"`, + compact: true, + rounded: true, + }) + ]); + + let votingActionObj; + votingActionObj = [ + m('.button-row', [yesButton, noButton]), + ]; + + return m('.VotingActions', [votingActionObj]); + } } const ViewProposalPage: m.Component<{ + scope: string, + snapshotId: string, identifier: string, }, { proposal: SnapshotProposal, votes: Vote[], - voteCount: number }> = { oninit: (vnode) => { vnode.state.votes = []; - vnode.state.voteCount = 0; - - const allProposals: SnapshotProposal[] = app.snapshot.proposalStore.getAll(); - vnode.state.proposal = allProposals.filter(proposal => proposal.ipfsHash === vnode.attrs.identifier)[0]; - - if (vnode.state.proposal) { - $.get(`https://hub.snapshot.page/api/${app.chain?.meta.chain.snapshot}/proposal/${vnode.state.proposal.ipfsHash}`).then((response) => { - if (response.status !== 'Success') { - var i = 0; - let votes: Vote[] = []; - for (const key in response) { - let vote: Vote = { - voterAddress: '', - choice: '', - timestamp: '', - }; - vote.voterAddress = key, - vote.timestamp = response[key].msg.timestamp; - vote.choice = vnode.state.proposal.choices[response[key].msg.payload.choice - 1]; - votes.push(vote); + + const snapshotId = vnode.attrs.snapshotId; + app.snapshot.fetchSnapshotProposals(snapshotId).then(response => { + + const allProposals: SnapshotProposal[] = app.snapshot.proposalStore.getAll(); + vnode.state.proposal = allProposals.filter(proposal => proposal.ipfsHash === vnode.attrs.identifier)[0]; + + if (vnode.state.proposal) { + const hubUrl = process.env.SNAPSHOT_APP_HUB_URL || 'https://testnet.snapshot.org'; + $.get(`${hubUrl}/api/${snapshotId}/proposal/${vnode.state.proposal.ipfsHash}`).then((response) => { + if (response.status !== 'Success') { + var i = 0; + let votes: Vote[] = []; + for (const key in response) { + let vote: Vote = { + voterAddress: '', + choice: '', + timestamp: '', + }; + vote.voterAddress = key, + vote.timestamp = response[key].msg.timestamp; + vote.choice = response[key].msg.payload.choice === 1 ? 'yes' : 'no'; + votes.push(vote); + } + vnode.state.votes = votes; + m.redraw(); } - vnode.state.voteCount = votes.length; - vnode.state.votes = votes.slice(0, 40); - m.redraw(); - } - }); - } + }); + } + }); }, oncreate: (vnode) => { mixpanel.track('PageVisit', { 'Page Name': 'ViewSnapShotProposalPage' }); @@ -137,10 +225,12 @@ const ViewProposalPage: m.Component<{ view: (vnode) => { return m(Sublayout, { class: 'ViewProposalPage', title: "Snapshot Proposal" }, [ m(ProposalHeader, { + snapshotId: vnode.attrs.snapshotId, proposal: vnode.state.proposal, }), m('.PinnedDivider', m('hr')), - m(VoteView, { votes: vnode.state.votes, voteCount: vnode.state.voteCount }) + vnode.state.votes && m(VoteView, { votes: vnode.state.votes }), + vnode.state.proposal && m(VoteAction, { choices: vnode.state.proposal.choices}) ]); } }; diff --git a/client/styles/components/new_thread_form.scss b/client/styles/components/new_thread_form.scss index 8fc8f02f841..d82033ec7fc 100644 --- a/client/styles/components/new_thread_form.scss +++ b/client/styles/components/new_thread_form.scss @@ -42,7 +42,15 @@ .new-thread-form-body { max-width: 100%; + .new-snapshot-proposal-form { + display: flex; + + form:last-child { + display: block; + } + } } + &.has-drafts .new-thread-form-body { max-width: calc(100% - 205px); @include xs-max { diff --git a/client/styles/pages/discussions/discussion_row.scss b/client/styles/pages/discussions/discussion_row.scss index 1b8728518c8..b9516690220 100644 --- a/client/styles/pages/discussions/discussion_row.scss +++ b/client/styles/pages/discussions/discussion_row.scss @@ -92,6 +92,9 @@ color: $primary-bg-color; } } + .m-l-20 { + margin-left: 20px; + } } } .row-right { diff --git a/package.json b/package.json index f30c77a560a..e899fdba4de 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,16 @@ "@commonwealth/chain-events": "0.6.5", "@cosmjs/launchpad": "^0.24.0-alpha.13", "@edgeware/node-types": "3.3.3", + "@ethersproject/abi": "^5.0.1", + "@ethersproject/address": "^5.0.1", + "@ethersproject/bytes": "^5.0.5", + "@ethersproject/constants": "^5.0.1", + "@ethersproject/contracts": "^5.0.1", + "@ethersproject/hash": "^5.0.6", + "@ethersproject/providers": "^5.0.12", + "@ethersproject/strings": "^5.0.5", + "@ethersproject/units": "^5.0.1", + "@ethersproject/wallet": "^5.0.1", "@glidejs/glide": "^3.4.1", "@keplr-wallet/types": "^0.9.0-alpha.1", "@lunie/cosmos-api": "git+https://git@github.com/hicommonwealth/cosmos-api.git#develop", @@ -64,6 +74,7 @@ "@polkadot/util-crypto": "^6.3.1", "@popperjs/core": "^2.0.6", "@sendgrid/mail": "^6.5.0", + "@snapshot-labs/snapshot.js": "github:snapshot-labs/snapshot.js#master", "@tendermint/amino-js": "npm:@tendermint/amino-js@0.5.1", "@typechain/ethers-v4": "^1.0.0", "@types/bn.js": "^4.11.6", @@ -134,7 +145,9 @@ "marked": "^2.0.0", "mini-css-extract-plugin": "^0.4.4", "mithril": "^2.0.4", + "mithril-datepicker": "^0.9.3", "mithril-infinite": "^1.2.9", + "mithril-timepicker": "^0.9.2", "mixpanel": "^0.10.2", "mixpanel-browser": "^2.27.1", "moment": "^2.23.0", diff --git a/server-test.ts b/server-test.ts index 7489666dce9..9f8dc433162 100644 --- a/server-test.ts +++ b/server-test.ts @@ -145,6 +145,28 @@ const resetServer = (debug=false): Promise => { type: 'token', base: 'ethereum', }); + const yearn = await models['Chain'].create({ + id: 'yearn', + network: 'yearn', + symbol: 'YFI', + name: 'Yearn', + icon_url: '/static/img/protocols/yearn.png', + active: true, + type: 'chain', + base: 'ethereum', + snapshot: 'ybaby.eth' + }); + const sushi = await models['Chain'].create({ + id: 'sushi', + network: 'sushi', + symbol: 'SUSHI', + name: 'Sushi', + icon_url: '/static/img/protocols/sushi.png', + active: true, + type: 'chain', + base: 'ethereum', + snapshot: 'sushi' + }); // Admin roles for specific communities await Promise.all([ @@ -240,6 +262,8 @@ const resetServer = (debug=false): Promise => { [ 'mainnet1.edgewa.re', 'edgeware' ], [ 'wss://mainnet.infura.io/ws', 'ethereum' ], [ 'wss://ropsten.infura.io/ws', 'alex', '0xFab46E002BbF0b4509813474841E0716E6730136'], + [ 'wss://mainnet.infura.io/ws', 'yearn', '0x0bc529c00c6401aef6d220be8c6ea1667f6ad93e'], + [ 'wss://mainnet.infura.io/ws', 'sushi', '0x6b3595068778dd592e39a122f4f5a5cf09c90fe2'], ]; await Promise.all(nodes.map(([ url, chain, address ]) => (models['ChainNode'].create({ chain, url, address })))); diff --git a/server/migrations/20210426074254-add_yearn_and_fei_chains.js b/server/migrations/20210426074254-add_yearn_and_fei_chains.js index df7e9638073..0069df6c7ea 100644 --- a/server/migrations/20210426074254-add_yearn_and_fei_chains.js +++ b/server/migrations/20210426074254-add_yearn_and_fei_chains.js @@ -41,9 +41,11 @@ module.exports = { await queryInterface.bulkInsert('ChainNodes', [{ chain: 'yearn', url: 'wss://mainnet.infura.io/ws', + address: '0x0bc529c00c6401aef6d220be8c6ea1667f6ad93e' }, { chain: 'fei', url: 'wss://mainnet.infura.io/ws', + address: '0x956F47F50A910163D8BF957Cf5846D573E7f87CA' }], { transaction: t }); }); }, diff --git a/server/migrations/20210530191127-add_sushi_chain.js b/server/migrations/20210530191127-add_sushi_chain.js new file mode 100644 index 00000000000..639371e5fa1 --- /dev/null +++ b/server/migrations/20210530191127-add_sushi_chain.js @@ -0,0 +1,44 @@ +'use strict'; + +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (t) => { + await queryInterface.bulkInsert('Chains', [{ + id: 'sushi', + symbol: 'SUSHI', + name: 'Sushi', + icon_url: '/static/img/protocols/sushi.png', + type: 'chain', + network: 'sushi', + active: true, + description: 'An example of an automated market maker (AMM).', + telegram: 'https://t.me/sushiswapEG', + website: 'https://sushi.com', + discord: 'https://discord.com/invite/MsVBwEc', + github: 'https://github.com/sushiswap', + collapsed_on_homepage: false, + base: 'ethereum', + snapshot: 'sushi' + } + ], { transaction: t }); + + await queryInterface.bulkInsert('ChainNodes', [{ + chain: 'sushi', + url: 'wss://mainnet.infura.io/ws', + address: '0x6b3595068778dd592e39a122f4f5a5cf09c90fe2' + }], { transaction: t }); + }); + }, + + down: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (t) => { + await queryInterface.bulkDelete('OffchainReactions', { chain: 'sushi' }, { transaction: t }); + await queryInterface.bulkDelete('OffchainComments', { chain: 'sushi' }, { transaction: t }); + await queryInterface.bulkDelete('OffchainThreads', { chain: 'sushi' }, { transaction: t }); + await queryInterface.bulkDelete('Addresses', { chain: 'sushi' }, { transaction: t }); + await queryInterface.bulkDelete('ChainEventTypes', { chain: 'sushi' }, { transaction: t }); + await queryInterface.bulkDelete('ChainNodes', { chain: 'sushi' }, { transaction: t }); + await queryInterface.bulkDelete('Chains', { id: ['sushi'] }, { transaction: t }); + }); + } +}; diff --git a/server/models/address.ts b/server/models/address.ts index 472fa82fd64..6bc72b8e852 100644 --- a/server/models/address.ts +++ b/server/models/address.ts @@ -264,6 +264,9 @@ export default ( } else if (chain.network === 'ethereum' || chain.network === 'moloch' || chain.network === 'alex' + || chain.network === 'yearn' + || chain.network === 'fei' + || chain.network === 'sushi' || chain.network === 'metacartel' || chain.network === 'commonwealth' || chain.type === 'token' diff --git a/server/router.ts b/server/router.ts index 16609ecebc4..075a296044e 100644 --- a/server/router.ts +++ b/server/router.ts @@ -121,6 +121,8 @@ import getTokenForum from './routes/getTokenForum'; import getSubstrateSpec from './routes/getSubstrateSpec'; import editSubstrateSpec from './routes/editSubstrateSpec'; +import { sendMessage } from './routes/snapshotAPI'; + function setupRouter( app, models, @@ -476,6 +478,8 @@ function setupRouter( // TODO: Change to GET /entities router.get('/bulkEntities', bulkEntities.bind(this, models)); + router.post('/snapshotAPI/sendMessage', sendMessage.bind(this)); + app.use('/api', router); } export default setupRouter; diff --git a/server/routes/snapshotAPI.ts b/server/routes/snapshotAPI.ts new file mode 100644 index 00000000000..77d82c650c0 --- /dev/null +++ b/server/routes/snapshotAPI.ts @@ -0,0 +1,25 @@ + +import { Request, Response, NextFunction } from 'express'; +import axios from 'axios'; + +export const sendMessage = async ( + req: Request, + res: Response, + next: NextFunction +) => { + const url = `${process.env.SNAPSHOT_APP_HUB_URL || 'https://testnet.snapshot.org'}/api/message`; + + axios.post(url, { + address: req.body.address, + msg: req.body.msg, + sig: req.body.sig, + }) + .then(response => { + console.log(`statusCode: ${response.status}`) + return res.json({ status: 'Success', message: response.data }); + }) + .catch(error => { + console.error(error); + return res.json({ status: 'Failure', message: error.response.data }); + }) +}; diff --git a/server/routes/updateChain.ts b/server/routes/updateChain.ts index a665f1b675a..fa4c7ad3cc1 100644 --- a/server/routes/updateChain.ts +++ b/server/routes/updateChain.ts @@ -40,7 +40,7 @@ const updateChain = async (models, req: Request, res: Response, next: NextFuncti } } - const { active, icon_url, symbol, type, name, description, website, discord, element, telegram, github, stagesEnabled, additionalStages, customDomain } = req.body; + const { active, icon_url, symbol, type, name, description, website, discord, element, telegram, github, stagesEnabled, additionalStages, customDomain, snapshot } = req.body; if (website && !urlHasValidHTTPPrefix(website)) { return next(new Error(Errors.InvalidWebsite)); @@ -70,6 +70,7 @@ const updateChain = async (models, req: Request, res: Response, next: NextFuncti chain.stagesEnabled = stagesEnabled; chain.additionalStages = additionalStages; chain.customDomain = customDomain; + chain.snapshot = snapshot; if (req.body['featured_topics[]']) chain.featured_topics = req.body['featured_topics[]']; await chain.save(); diff --git a/server/util/snapshotUtils.ts b/server/util/snapshotUtils.ts new file mode 100644 index 00000000000..41c04585ff4 --- /dev/null +++ b/server/util/snapshotUtils.ts @@ -0,0 +1,14 @@ +export function jsonParse(input, fallback?) { + try { + return JSON.parse(input); + } catch (err) { + return fallback || {}; + } +} + +export function sendError(res, description, status = 500) { + return res.status(status).json({ + error: 'unauthorized', + error_description: description + }); +} \ No newline at end of file diff --git a/static/img/protocols/sushi.png b/static/img/protocols/sushi.png new file mode 100644 index 00000000000..5ab17a6c2c1 Binary files /dev/null and b/static/img/protocols/sushi.png differ diff --git a/webpack/webpack.common.js b/webpack/webpack.common.js index 8724460267e..ac55e7156e7 100644 --- a/webpack/webpack.common.js +++ b/webpack/webpack.common.js @@ -89,6 +89,7 @@ module.exports = { path.resolve(__dirname, '../client'), path.resolve(__dirname, '../shared'), path.resolve(__dirname, '../eth/types'), + path.resolve(__dirname, '../node_modules/@snapshot-labs/snapshot.js'), ], use: { loader: 'ts-loader'