From 10434cde50815c9d4bcccf88a71b56a1d8924f3b Mon Sep 17 00:00:00 2001 From: fstar1129 <41280117+fstar1129@users.noreply.github.com> Date: Fri, 11 Jun 2021 15:48:56 -0700 Subject: [PATCH] Add Snapshot Creation (#1216) * update snapshot proposal page * create snpashot proposal workflow * update UI * utilize snapshot js * add snapshot create proposal * add sushi token for testing purpose * correct wrong code * complete creating snapshot proposal * update manage community form --- client/scripts/app.ts | 12 +- .../chain/ethereum/sushi/adapter.ts | 63 ++++ .../controllers/chain/ethereum/sushi/api.ts | 5 + .../controllers/chain/ethereum/sushi/chain.ts | 8 + client/scripts/controllers/server/snapshot.ts | 3 +- client/scripts/helpers/index.ts | 10 + client/scripts/helpers/snapshot_client.ts | 6 + client/scripts/models/ChainInfo.ts | 4 +- client/scripts/models/types.ts | 7 + .../scripts/views/components/sidebar/index.ts | 15 +- .../chain_metadata_management_table.ts | 14 +- .../pages/new_snapshot_proposal/index.ts | 23 ++ .../new_proposal_form.ts | 352 ++++++++++++++++++ .../views/pages/snapshot_proposals/index.ts | 39 +- .../pages/snapshot_proposals/proposal_row.ts | 6 +- .../pages/view_snapshot_proposal/index.ts | 178 ++++++--- client/styles/components/new_thread_form.scss | 8 + .../pages/discussions/discussion_row.scss | 3 + package.json | 13 + server-test.ts | 24 ++ ...20210426074254-add_yearn_and_fei_chains.js | 2 + .../20210530191127-add_sushi_chain.js | 44 +++ server/models/address.ts | 3 + server/router.ts | 4 + server/routes/snapshotAPI.ts | 25 ++ server/routes/updateChain.ts | 3 +- server/util/snapshotUtils.ts | 14 + static/img/protocols/sushi.png | Bin 0 -> 31419 bytes webpack/webpack.common.js | 1 + 29 files changed, 806 insertions(+), 83 deletions(-) create mode 100644 client/scripts/controllers/chain/ethereum/sushi/adapter.ts create mode 100644 client/scripts/controllers/chain/ethereum/sushi/api.ts create mode 100644 client/scripts/controllers/chain/ethereum/sushi/chain.ts create mode 100644 client/scripts/helpers/snapshot_client.ts create mode 100644 client/scripts/views/pages/new_snapshot_proposal/index.ts create mode 100644 client/scripts/views/pages/new_snapshot_proposal/new_proposal_form.ts create mode 100644 server/migrations/20210530191127-add_sushi_chain.js create mode 100644 server/routes/snapshotAPI.ts create mode 100644 server/util/snapshotUtils.ts create mode 100644 static/img/protocols/sushi.png 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 0000000000000000000000000000000000000000..5ab17a6c2c1a3adc705139d6abce961aca6288f7 GIT binary patch literal 31419 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 literal 0 HcmV?d00001 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'