From edd0ff119cc7c2a94dc0e66d9371eb28b4463902 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Fri, 25 Mar 2022 11:16:09 -0700 Subject: [PATCH] Improve proposal template UX (#440) * Preview proposal message JSON when creating. Renamed messages to actions on proposal create page. * Recognize and display message templates on existing proposals. * Fixed spend cosmos msg detection. * Formatted. * PR fixes. * Fixed prettier lint error. --- apps/dapp/components/ProposalDetails.tsx | 127 +++++++- apps/dapp/components/ProposalForm.tsx | 231 ++++++++------ apps/dapp/components/input/AddressInput.tsx | 3 + .../dapp/components/input/CodeMirrorInput.tsx | 47 +-- apps/dapp/components/input/NumberInput.tsx | 3 + apps/dapp/components/input/SelectInput.tsx | 3 + apps/dapp/components/input/TextInput.tsx | 3 + apps/dapp/components/input/ToggleInput.tsx | 3 + .../proposals/[proposalId].tsx | 9 +- .../[contractAddress]/proposals/create.tsx | 28 +- .../proposals/[proposalId].tsx | 3 + .../[contractAddress]/proposals/create.tsx | 24 +- apps/dapp/templates/addToken.tsx | 114 ++++--- apps/dapp/templates/changeMembers.tsx | 153 +++++---- apps/dapp/templates/configUpdate.tsx | 151 ++++++--- apps/dapp/templates/custom.tsx | 60 ++-- apps/dapp/templates/mint.tsx | 85 +++-- apps/dapp/templates/removeToken.tsx | 82 +++-- apps/dapp/templates/spend.tsx | 139 +++++--- apps/dapp/templates/stake.tsx | 297 +++++++++++------- apps/dapp/templates/templateList.tsx | 104 +++++- apps/dapp/util/messagehelpers.ts | 31 +- 22 files changed, 1084 insertions(+), 616 deletions(-) diff --git a/apps/dapp/components/ProposalDetails.tsx b/apps/dapp/components/ProposalDetails.tsx index 81f84ebf3..a505fe37c 100644 --- a/apps/dapp/components/ProposalDetails.tsx +++ b/apps/dapp/components/ProposalDetails.tsx @@ -1,23 +1,25 @@ -import { ReactNode } from 'react' +import { ReactNode, useState } from 'react' import { useRouter } from 'next/router' import { - atom, SetterOrUpdater, - useRecoilState, useRecoilValue, useRecoilValueLoadable, useSetRecoilState, } from 'recoil' import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate' +import { CosmosMsgFor_Empty } from '@dao-dao/types/contracts/cw3-dao' import { CheckIcon, ExternalLinkIcon, + EyeIcon, + EyeOffIcon, SparklesIcon, XIcon, } from '@heroicons/react/outline' +import { FormProvider, useForm } from 'react-hook-form' import toast from 'react-hot-toast' import { ProposalStatus } from '@components' @@ -40,6 +42,11 @@ import { walletVotedSelector, } from 'selectors/proposals' import { walletTokenBalanceLoading } from 'selectors/treasury' +import { + FromCosmosMsgProps, + MessageTemplate, + messageTemplateAndValuesForDecodedCosmosMsg, +} from 'templates/templateList' import { cleanChainError } from 'util/cleanChainError' import { CHAIN_TXN_URL_PREFIX } from 'util/constants' import { @@ -457,19 +464,87 @@ export function ProposalDetailsSidebar({ ) } -const proposalActionLoading = atom({ - key: 'proposalActionLoading', - default: false, -}) +interface ProposalMessageTemplateListItemProps { + template: MessageTemplate + values: any + contractAddress: string + multisig?: boolean +} + +function ProposalMessageTemplateListItem({ + template, + values, + contractAddress, + multisig, +}: ProposalMessageTemplateListItemProps) { + const formMethods = useForm({ + defaultValues: values, + }) + + return ( + +
+ field} + readOnly + contractAddress={contractAddress} + multisig={multisig} + /> + +
+ ) +} + +interface ProposalMessageTemplateListProps { + msgs: CosmosMsgFor_Empty[] + contractAddress: string + multisig?: boolean + fromCosmosMsgProps: FromCosmosMsgProps +} + +function ProposalMessageTemplateList({ + msgs, + contractAddress, + multisig, + fromCosmosMsgProps, +}: ProposalMessageTemplateListProps) { + const components: ReactNode[] = msgs.map((msg, index) => { + const decoded = decodeMessages([msg])[0] + const data = messageTemplateAndValuesForDecodedCosmosMsg( + decoded, + fromCosmosMsgProps + ) + + return data ? ( + + ) : ( + // If no message template found, render raw message. + + ) + }) + + return <>{components} +} export function ProposalDetails({ contractAddress, proposalId, multisig, + fromCosmosMsgProps, }: { contractAddress: string proposalId: number multisig?: boolean + fromCosmosMsgProps: FromCosmosMsgProps }) { const router = useRouter() const proposal = useRecoilValue( @@ -490,15 +565,15 @@ export function ProposalDetails({ walletVotedSelector({ contractAddress, proposalId }) ) - const [actionLoading, setActionLoading] = useRecoilState( - proposalActionLoading - ) + const [actionLoading, setActionLoading] = useState(false) const wallet = useRecoilValue(walletAddressSelector) // If token balances are loading we don't know if the user is a // member or not. const tokenBalancesLoading = useRecoilValue(walletTokenBalanceLoading(wallet)) + const [showRaw, setShowRaw] = useState(false) + if (!proposal) { router.replace(`/${multisig ? 'multisig' : 'dao'}/${contractAddress}`) return
Error
@@ -521,12 +596,12 @@ export function ProposalDetails({ ) ) - const decodedMessages = decodeMessages(proposal) + const decodedMessages = decodeMessages(proposal.msgs) return (
-

{proposal.title}

+

{proposal.title}

{actionLoading && (
@@ -574,10 +649,36 @@ export function ProposalDetails({
{decodedMessages?.length ? ( - + showRaw ? ( + + ) : ( + + ) ) : (

       )}
+      
       
{ + messages: CosmosMsgFor_Empty[] +} + +interface ProposalFormProps { + onSubmit: (data: ProposalData) => void + contractAddress: string + loading: boolean + toCosmosMsgProps: ToCosmosMsgProps + multisig?: boolean +} + export function ProposalForm({ onSubmit, contractAddress, loading, + toCosmosMsgProps, multisig, -}: { - onSubmit: (data: ProposalData) => void - contractAddress: string - loading: boolean - multisig?: boolean -}) { +}: ProposalFormProps) { const wallet = useRecoilValue(walletAddress) const contractConfig = useRecoilValue( contractConfigSelector({ contractAddress, multisig: !!multisig }) @@ -48,7 +61,7 @@ export function ProposalForm({ const wrapper = new ContractConfigWrapper(contractConfig) const govTokenDecimals = wrapper.gov_token_decimals - const formMethods = useForm() + const formMethods = useForm() // Unpack here because we use these at the top level as well as // inside of nested components. @@ -64,8 +77,13 @@ export function ProposalForm({ const proposalDescription = watch('description') const proposalTitle = watch('title') + const proposalMessages = watch('messages') - const { fields, append, remove } = useFieldArray({ + const { + fields: messageFields, + append, + remove, + } = useFieldArray({ name: 'messages', control, shouldUnregister: true, @@ -75,19 +93,35 @@ export function ProposalForm({
((d) => - onSubmit(d as ProposalData) + onSubmit={handleSubmit((d) => + onSubmit({ + ...d, + messages: (d.messages as MessageTemplate[]) + .map((m) => messageTemplateToCosmosMsg(m, toCosmosMsgProps)) + // Filter out undefined messages. + .filter(Boolean) as CosmosMsgFor_Empty[], + }) )} > -
-

- {proposalTitle} -

-
-
- -
-
+ {showPreview && ( + <> +
+

{proposalTitle}

+
+
+ +
+ messageTemplateToCosmosMsg(m, toCosmosMsgProps)) + // Filter out undefined messages. + .filter(Boolean) as CosmosMsgFor_Empty[] + )} + /> + + )} +
-
-
    - {fields.map((data, index) => { - const label = (data as any).label - const template = messageTemplates.find( - (template) => template.label === label - ) - if (!template) { - // We guarentee by construction that this should never - // happen but might as well make it pretty if it does. - return ( -
    -

    Internal error finding template for message.

    - -
    - ) - } - const Component = template.component - return ( -
  • - remove(index)} - getLabel={(fieldName) => `messages.${index}.${fieldName}`} - errors={(errors.messages && errors.messages[index]) || {}} - multisig={multisig} - /> -
  • - ) - })} -
-
-
- Add message +
+
-
    - {messageTemplates - .filter(({ contractSupport }) => { - switch (contractSupport) { - case ContractSupport.Both: - return true - case ContractSupport.Multisig: - return multisig - case ContractSupport.DAO: - return !multisig - } - }) - .map(({ label, getDefaults }, index) => ( -
  • - +
      + {messageFields.map((data, index) => { + const label = (data as any).label + const template = messageTemplates.find( + (template) => template.label === label + ) + if (!template) { + // We guarantee by construction that this should never + // happen but might as well make it pretty if it does. + return ( +
      +

      Internal error finding template for message.

      + +
      + ) + } + const Component = template.component + return ( +
    • + remove(index)} + getLabel={(fieldName) => `messages.${index}.${fieldName}`} + errors={(errors.messages && errors.messages[index]) || {}} + multisig={multisig} + />
    • - ))} + ) + })}
    +
    +
    + Add action +
    +
      + {messageTemplates + .filter(({ contractSupport }) => { + switch (contractSupport) { + case ContractSupport.Both: + return true + case ContractSupport.Multisig: + return multisig + case ContractSupport.DAO: + return !multisig + } + }) + .map(({ label, getDefaults }, index) => ( +
    • + +
    • + ))} +
    +
>({ validation, onChange, border = true, + disabled = false, }: { label: FieldName register: UseFormRegister @@ -22,6 +23,7 @@ export function AddressInput>({ validation?: Validate>[] error?: FieldError border?: boolean + disabled?: boolean }) { const validate = validation?.reduce( (a, v) => ({ ...a, [v.toString()]: v }), @@ -33,6 +35,7 @@ export function AddressInput>({ className={`input text-sm font-mono ${error ? ' input-error' : ''} ${border ? ' input-bordered' : ''}`} + disabled={disabled} {...register(label, { validate, onChange })} /> ) diff --git a/apps/dapp/components/input/CodeMirrorInput.tsx b/apps/dapp/components/input/CodeMirrorInput.tsx index 97cf1029e..f2b6dd930 100644 --- a/apps/dapp/components/input/CodeMirrorInput.tsx +++ b/apps/dapp/components/input/CodeMirrorInput.tsx @@ -1,5 +1,6 @@ import 'codemirror/lib/codemirror.css' import 'codemirror/theme/material.css' + import { UnControlled as CodeMirror } from 'react-codemirror2' import { FieldError, @@ -8,29 +9,29 @@ import { Validate, Control, Controller, + FieldValues, } from 'react-hook-form' import { useThemeContext } from 'ui' -import 'codemirror/lib/codemirror.css' -import 'codemirror/theme/material.css' // This check is to prevent this import to be server side rendered. if (typeof window !== 'undefined' && typeof window.navigator !== 'undefined') { require('codemirror/mode/javascript/javascript.js') } -export function CodeMirrorInput< - FieldValues, - FieldName extends Path ->({ +interface CodeMirrorInputProps> { + label: U + control?: Control + validation?: Validate>[] + error?: FieldError + readOnly?: boolean +} + +export function CodeMirrorInput>({ label, control, validation, -}: { - label: FieldName - control: Control - validation?: Validate>[] - error?: FieldError -}) { + readOnly = false, +}: CodeMirrorInputProps) { const validate = validation?.reduce( (a, v) => ({ ...a, [v.toString()]: v }), {} @@ -51,6 +52,7 @@ export function CodeMirrorInput< tabSize: 2, gutters: ['CodeMirror-lint-markers'], lint: true, + readOnly, } return ( @@ -59,17 +61,16 @@ export function CodeMirrorInput< name={label} rules={{ validate: validate }} shouldUnregister - render={({ field: { onChange, onBlur, ref } }) => { - return ( - onChange(value)} - onBlur={(_instance, _event) => onBlur()} - ref={ref} - options={cmOptions} - className="rounded" - /> - ) - }} + render={({ field: { onChange, onBlur, ref, value } }) => ( + onChange(value)} + onBlur={(_instance, _event) => onBlur()} + ref={ref} + options={cmOptions} + className="rounded" + value={value} + /> + )} /> ) } diff --git a/apps/dapp/components/input/NumberInput.tsx b/apps/dapp/components/input/NumberInput.tsx index 7ef081832..3cd295e1a 100644 --- a/apps/dapp/components/input/NumberInput.tsx +++ b/apps/dapp/components/input/NumberInput.tsx @@ -26,6 +26,7 @@ export function NumberInput>({ defaultValue, step, border = true, + disabled = false, }: { label: FieldName register: UseFormRegister @@ -35,6 +36,7 @@ export function NumberInput>({ defaultValue?: string step?: string | number border?: boolean + disabled?: boolean }) { const validate = validation?.reduce( (a, v) => ({ ...a, [v.toString()]: v }), @@ -48,6 +50,7 @@ export function NumberInput>({ className={`input ${error ? ' input-error' : ''} ${border ? ' input-bordered' : ''}`} + disabled={disabled} {...register(label, { validate, onChange })} /> ) diff --git a/apps/dapp/components/input/SelectInput.tsx b/apps/dapp/components/input/SelectInput.tsx index 919117e73..ecf1a3469 100644 --- a/apps/dapp/components/input/SelectInput.tsx +++ b/apps/dapp/components/input/SelectInput.tsx @@ -16,6 +16,7 @@ export function SelectInput>({ children, border = true, defaultValue, + disabled = false, }: { label: FieldName register: UseFormRegister @@ -24,6 +25,7 @@ export function SelectInput>({ children: ReactNode border?: boolean defaultValue?: string + disabled?: boolean }) { const validate = validation?.reduce( (a, v) => ({ ...a, [v.toString()]: v }), @@ -35,6 +37,7 @@ export function SelectInput>({ ${error ? ' select-error' : ''} ${border ? ' select-bordered' : ''}`} defaultValue={defaultValue} + disabled={disabled} {...register(label, { validate })} > {children} diff --git a/apps/dapp/components/input/TextInput.tsx b/apps/dapp/components/input/TextInput.tsx index 76c02a6cc..83f6b728b 100644 --- a/apps/dapp/components/input/TextInput.tsx +++ b/apps/dapp/components/input/TextInput.tsx @@ -21,12 +21,14 @@ export function TextInput>({ error, validation, border = true, + disabled = false, }: { label: FieldName register: UseFormRegister validation?: Validate>[] error?: FieldError border?: boolean + disabled?: boolean }) { const validate = validation?.reduce( (a, v) => ({ ...a, [v.toString()]: v }), @@ -38,6 +40,7 @@ export function TextInput>({ className={`input ${error ? ' input-error' : ''} ${border ? ' input-bordered' : ''}`} + disabled={disabled} {...register(label, { validate })} /> ) diff --git a/apps/dapp/components/input/ToggleInput.tsx b/apps/dapp/components/input/ToggleInput.tsx index d4bf0aba6..379aefa4d 100644 --- a/apps/dapp/components/input/ToggleInput.tsx +++ b/apps/dapp/components/input/ToggleInput.tsx @@ -22,12 +22,14 @@ export function ToggleInput>({ register, validation, onChange, + disabled = false, }: { label: FieldName register: UseFormRegister validation?: Validate>[] error?: FieldError onChange?: ChangeEventHandler + disabled?: boolean }) { const validate = validation?.reduce( (a, v) => ({ ...a, [v.toString()]: v }), @@ -38,6 +40,7 @@ export function ToggleInput>({ type="checkbox" defaultChecked={true} className="toggle toggle-lg" + disabled={disabled} {...register(label, { validate, onChange })} /> ) diff --git a/apps/dapp/pages/dao/[contractAddress]/proposals/[proposalId].tsx b/apps/dapp/pages/dao/[contractAddress]/proposals/[proposalId].tsx index 08e2dc372..58e18b461 100644 --- a/apps/dapp/pages/dao/[contractAddress]/proposals/[proposalId].tsx +++ b/apps/dapp/pages/dao/[contractAddress]/proposals/[proposalId].tsx @@ -11,12 +11,14 @@ import { } from 'components/ProposalDetails' import Sidebar from 'components/Sidebar' import { daoSelector } from 'selectors/daos' +import { cw20TokenInfo } from 'selectors/treasury' const Proposal: NextPage = () => { const router = useRouter() const proposalKey = router.query.proposalId as string const contractAddress = router.query.contractAddress as string - const sigInfo = useRecoilValue(daoSelector(contractAddress)) + const daoInfo = useRecoilValue(daoSelector(contractAddress)) + const govTokenInfo = useRecoilValue(cw20TokenInfo(daoInfo.gov_token)) const expanded = useRecoilValue(sidebarExpandedAtom) return ( @@ -25,13 +27,16 @@ const Proposal: NextPage = () => {
diff --git a/apps/dapp/pages/dao/[contractAddress]/proposals/create.tsx b/apps/dapp/pages/dao/[contractAddress]/proposals/create.tsx index d86b5b8fe..edcbc2a74 100644 --- a/apps/dapp/pages/dao/[contractAddress]/proposals/create.tsx +++ b/apps/dapp/pages/dao/[contractAddress]/proposals/create.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import type { NextPage } from 'next' import { NextRouter, useRouter } from 'next/router' -import { useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil' +import { useRecoilValue, useSetRecoilState } from 'recoil' import { ExecuteResult } from '@cosmjs/cosmwasm-stargate' import { findAttribute } from '@cosmjs/stargate/build/logs' @@ -20,7 +20,6 @@ import { } from 'selectors/cosm' import { daoSelector } from 'selectors/daos' import { cw20TokenInfo } from 'selectors/treasury' -import { MessageTemplate, messageTemplates } from 'templates/templateList' import { cleanChainError } from 'util/cleanChainError' import { expirationExpired } from 'util/expiration' @@ -42,23 +41,6 @@ const ProposalCreate: NextPage = () => { const onProposalSubmit = async (d: ProposalData) => { setProposalLoading(true) - let cosmMsgs = d.messages.map((m: MessageTemplate) => { - const template = messageTemplates.find( - (template) => template.label === m.label - ) - - const toCosmosMsg = template?.toCosmosMsg - - // Unreachable. - if (!toCosmosMsg) return {} - - return toCosmosMsg(m as any, { - sigAddress: contractAddress, - govAddress: daoInfo.gov_token, - govDecimals: tokenInfo.decimals, - multisig: false, - }) - }) if (signingClient == null) { toast.error('No signing client. Is your wallet connected?') @@ -112,7 +94,7 @@ const ProposalCreate: NextPage = () => { propose: { title: d.title, description: d.description, - msgs: cosmMsgs, + msgs: d.messages, }, }, 'auto' @@ -151,6 +133,12 @@ const ProposalCreate: NextPage = () => { onSubmit={onProposalSubmit} contractAddress={contractAddress} loading={proposalLoading} + toCosmosMsgProps={{ + sigAddress: contractAddress, + govAddress: daoInfo.gov_token, + govDecimals: tokenInfo.decimals, + multisig: false, + }} />
diff --git a/apps/dapp/pages/multisig/[contractAddress]/proposals/[proposalId].tsx b/apps/dapp/pages/multisig/[contractAddress]/proposals/[proposalId].tsx index 0ba3dcbee..3b412d00e 100644 --- a/apps/dapp/pages/multisig/[contractAddress]/proposals/[proposalId].tsx +++ b/apps/dapp/pages/multisig/[contractAddress]/proposals/[proposalId].tsx @@ -32,6 +32,9 @@ const MultisigProposal: NextPage = () => {
diff --git a/apps/dapp/pages/multisig/[contractAddress]/proposals/create.tsx b/apps/dapp/pages/multisig/[contractAddress]/proposals/create.tsx index 92a9b06e0..ac2117e43 100644 --- a/apps/dapp/pages/multisig/[contractAddress]/proposals/create.tsx +++ b/apps/dapp/pages/multisig/[contractAddress]/proposals/create.tsx @@ -18,7 +18,6 @@ import { walletAddress as walletAddressSelector, } from 'selectors/cosm' import { sigSelector } from 'selectors/multisigs' -import { MessageTemplate, messageTemplates } from 'templates/templateList' import { cleanChainError } from 'util/cleanChainError' const MultisigProposalCreate: NextPage = () => { @@ -35,21 +34,6 @@ const MultisigProposalCreate: NextPage = () => { const onProposalSubmit = async (d: ProposalData) => { setProposalLoading(true) - let cosmMsgs = d.messages.map((m: MessageTemplate) => { - const toCosmosMsg = messageTemplates.find( - (template) => template.label === m.label - )?.toCosmosMsg - - // Unreachable. - if (!toCosmosMsg) return {} - - return toCosmosMsg(m as any, { - sigAddress: contractAddress, - govAddress: sigInfo.group_address, - govDecimals: 0, - multisig: true, - }) - }) await signingClient ?.execute( @@ -59,7 +43,7 @@ const MultisigProposalCreate: NextPage = () => { propose: { title: d.title, description: d.description, - msgs: cosmMsgs, + msgs: d.messages, }, }, 'auto' @@ -95,6 +79,12 @@ const MultisigProposalCreate: NextPage = () => { contractAddress={contractAddress} onSubmit={onProposalSubmit} loading={proposalLoading} + toCosmosMsgProps={{ + sigAddress: contractAddress, + govAddress: sigInfo.group_address, + govDecimals: 0, + multisig: true, + }} multisig />
diff --git a/apps/dapp/templates/addToken.tsx b/apps/dapp/templates/addToken.tsx index fd9356f9b..e3051eafb 100644 --- a/apps/dapp/templates/addToken.tsx +++ b/apps/dapp/templates/addToken.tsx @@ -1,16 +1,24 @@ +import { useEffect } from 'react' + +import { useRecoilValueLoadable } from 'recoil' + +import { Config } from 'util/contractConfigWrapper' +import { validateContractAddress, validateRequired } from 'util/formValidation' +import { makeWasmMessage } from 'util/messagehelpers' + import { AddressInput } from '@components/input/AddressInput' import { InputErrorMessage } from '@components/input/InputErrorMessage' import { InputLabel } from '@components/input/InputLabel' import { LogoNoBorder } from '@components/Logo' import { XIcon } from '@heroicons/react/outline' -import { useEffect } from 'react' -import { FieldErrors, useFormContext } from 'react-hook-form' -import { useRecoilValueLoadable } from 'recoil' +import { useFormContext } from 'react-hook-form' import { tokenConfig } from 'selectors/daos' -import { Config } from 'util/contractConfigWrapper' -import { validateContractAddress, validateRequired } from 'util/formValidation' -import { makeWasmMessage } from 'util/messagehelpers' -import { ToCosmosMsgProps } from './templateList' + +import { + TemplateComponent, + TemplateComponentProps, + ToCosmosMsgProps, +} from './templateList' export interface AddTokenData { address: string @@ -19,11 +27,9 @@ export interface AddTokenData { export const addTokenDefaults = ( _walletAddress: string, _contractConfig: Config -) => { - return { - address: '', - } -} +): AddTokenData => ({ + address: '', +}) export const TokenInfoDisplay = ({ address, @@ -42,7 +48,7 @@ export const TokenInfoDisplay = ({ } else { clearError() } - }, [tokenInfo]) + }, [tokenInfo, setError, clearError]) return (
@@ -63,44 +69,51 @@ export const TokenInfoDisplay = ({ ) } +interface TokenSelectorProps + extends Pick< + TemplateComponentProps, + 'getLabel' | 'onRemove' | 'errors' | 'readOnly' + > { + symbol: string + title: string +} + export const TokenSelector = ({ getLabel, onRemove, errors, + readOnly, symbol, title, -}: { - getLabel: (field: string) => string - onRemove: () => void - errors: FieldErrors - symbol: string - title: string -}) => { +}: TokenSelectorProps) => { const { register, watch, setError, clearErrors } = useFormContext() const tokenAddress = watch(getLabel('address')) return ( -
+

{symbol}

{title}

- + {onRemove && ( + + )}
- +
string - onRemove: () => void - errors: FieldErrors - multisig?: boolean -}) => { - return ( - - ) -} + readOnly, +}) => ( + +) export const transformAddTokenToCosmos = ( self: AddTokenData, @@ -156,3 +161,18 @@ export const transformAddTokenToCosmos = ( }, }) } + +export const transformCosmosToAddToken = ( + msg: Record +): AddTokenData | null => + 'wasm' in msg && + 'execute' in msg.wasm && + 'update_cw20_token_list' in msg.wasm.execute.msg && + 'to_add' in msg.wasm.execute.msg.update_cw20_token_list && + msg.wasm.execute.msg.update_cw20_token_list.to_add.length === 1 && + 'to_remove' in msg.wasm.execute.msg.update_cw20_token_list && + msg.wasm.execute.msg.update_cw20_token_list.to_remove.length === 0 + ? { + address: msg.wasm.execute.msg.update_cw20_token_list.to_add[0], + } + : null diff --git a/apps/dapp/templates/changeMembers.tsx b/apps/dapp/templates/changeMembers.tsx index 5def60b72..94b181eb2 100644 --- a/apps/dapp/templates/changeMembers.tsx +++ b/apps/dapp/templates/changeMembers.tsx @@ -1,22 +1,24 @@ -import { XIcon } from '@heroicons/react/outline' -import { AddressInput } from '@components/input/AddressInput' -import { NumberInput } from '@components/input/NumberInput' -import { InputErrorMessage } from '@components/input/InputErrorMessage' -import { InputLabel } from '@components/input/InputLabel' -import { CosmosMsgFor_Empty } from '@dao-dao/types/contracts/cw3-dao' -import { FieldErrors, useFieldArray, useFormContext } from 'react-hook-form' +import { useRecoilValue } from 'recoil' + import { Config } from 'util/contractConfigWrapper' -import { ToCosmosMsgProps } from './templateList' -import { PlusMinusButton } from '@components/PlusMinusButton' import { validateAddress, validatePositive, validateRequired, } from 'util/formValidation' -import { useRecoilValue } from 'recoil' -import { listMembers } from 'selectors/multisigs' import { makeWasmMessage } from 'util/messagehelpers' +import { AddressInput } from '@components/input/AddressInput' +import { InputErrorMessage } from '@components/input/InputErrorMessage' +import { InputLabel } from '@components/input/InputLabel' +import { NumberInput } from '@components/input/NumberInput' +import { PlusMinusButton } from '@components/PlusMinusButton' +import { XIcon } from '@heroicons/react/outline' +import { useFieldArray, useFormContext } from 'react-hook-form' +import { listMembers } from 'selectors/multisigs' + +import { TemplateComponent, ToCosmosMsgProps } from './templateList' + export interface Member { addr: string weight: number @@ -30,32 +32,22 @@ export interface ChangeMembersData { export const changeMembersDefaults = ( _walletAddress: string, _contractConfig: Config -): ChangeMembersData => { - return { - toAdd: [], - toRemove: [], - } -} +): ChangeMembersData => ({ + toAdd: [], + toRemove: [], +}) -const memberDefaults = (): Member => { - return { - addr: '', - weight: 0, - } -} +const memberDefaults = (): Member => ({ + addr: '', + weight: 0, +}) -export const ChangeMembersComponent = ({ +export const ChangeMembersComponent: TemplateComponent = ({ contractAddress, getLabel, onRemove, errors, - multisig, -}: { - contractAddress: string - getLabel: (field: string) => string - onRemove: () => void - errors: FieldErrors - multisig?: boolean + readOnly, }) => { const { register, control } = useFormContext() @@ -88,15 +80,17 @@ export const ChangeMembersComponent = ({ 'Address is already a member of the multisig.' return ( -
+

🖋

Manage members

- + {onRemove && ( + + )}

To Add

    @@ -113,12 +107,13 @@ export const ChangeMembersComponent = ({ {addFields.map((_data, index) => { const newGetLabel = (label: string) => `${getLabel('toAdd')}.${index}.${label}` - const addrError = - errors.toAdd && errors.toAdd[index] && errors.toAdd[index].addr - const weightError = - errors.toAdd && errors.toAdd[index] && errors.toAdd[index].weight + const addrError = errors?.toAdd?.[index]?.addr + const weightError = errors?.toAdd?.[index]?.weight return ( -
    +
    @@ -141,28 +137,33 @@ export const ChangeMembersComponent = ({ error={weightError} validation={[validatePositive, validateRequired]} border={false} + disabled={readOnly} />
    - + {!readOnly && ( + + )}
) })} -
- { - addAppend(memberDefaults()) - }} - onMinus={() => { - addRemove(addFields.length - 1) - }} - disableMinus={addFields.length == 0} - /> -
+ {!readOnly && ( +
+ { + addAppend(memberDefaults()) + }} + onMinus={() => { + addRemove(addFields.length - 1) + }} + disableMinus={addFields.length == 0} + /> +
+ )}

To Remove

{removeFields.length != 0 && (
@@ -173,10 +174,9 @@ export const ChangeMembersComponent = ({ )} {removeFields.map((_data, index) => { const newGetLabel = () => `${getLabel('toRemove')}.${index}` - const addrError = - errors.toRemove && errors.toRemove[index] && errors.toRemove[index] + const addrError = errors?.toRemove?.[index] return ( -
+
) })} -
- { - removeAppend('') - }} - onMinus={() => { - removeRemove(addFields.length - 1) - }} - disableMinus={removeFields.length == 0} - /> -
+ {!readOnly && ( +
+ { + removeAppend('') + }} + onMinus={() => { + removeRemove(addFields.length - 1) + }} + disableMinus={removeFields.length == 0} + /> +
+ )}
) } @@ -229,3 +232,17 @@ export const transformChangeMembersToCosmos = ( }, }) } + +export const transformCosmosToChangeMembers = ( + msg: Record +): ChangeMembersData | null => + 'wasm' in msg && + 'execute' in msg.wasm && + 'update_members' in msg.wasm.execute.msg && + 'add' in msg.wasm.execute.msg.update_members && + 'remove' in msg.wasm.execute.msg.update_members + ? { + toAdd: msg.wasm.execute.msg.update_members.add, + toRemove: msg.wasm.execute.msg.update_members.remove, + } + : null diff --git a/apps/dapp/templates/configUpdate.tsx b/apps/dapp/templates/configUpdate.tsx index 89a051fbe..af8598cb6 100644 --- a/apps/dapp/templates/configUpdate.tsx +++ b/apps/dapp/templates/configUpdate.tsx @@ -1,13 +1,15 @@ +import { useState } from 'react' + +import { Config as DAOConfig } from '@dao-dao/types/contracts/cw3-dao' +import { InformationCircleIcon, XIcon } from '@heroicons/react/outline' +import { useFormContext } from 'react-hook-form' + import { InputErrorMessage } from '@components/input/InputErrorMessage' import { InputLabel } from '@components/input/InputLabel' import { NumberInput } from '@components/input/NumberInput' import { TextInput } from '@components/input/TextInput' import { ToggleInput } from '@components/input/ToggleInput' -import { Config as DAOConfig } from '@dao-dao/types/contracts/cw3-dao' -import { InformationCircleIcon, XIcon } from '@heroicons/react/outline' import { DEFAULT_MAX_VOTING_PERIOD_SECONDS } from 'pages/dao/create' -import { useState } from 'react' -import { FieldErrors, useFormContext } from 'react-hook-form' import { Config } from 'util/contractConfigWrapper' import { secondsToHms, @@ -22,7 +24,12 @@ import { validateUrl, } from 'util/formValidation' import { makeWasmMessage } from 'util/messagehelpers' -import { ToCosmosMsgProps } from './templateList' + +import { + FromCosmosMsgProps, + TemplateComponent, + ToCosmosMsgProps, +} from './templateList' enum ThresholdMode { Threshold, @@ -46,7 +53,7 @@ export interface DAOConfigUpdateData { defaultQuorum: string } -export const DAOConfigUpdateDefaults = ( +export const daoConfigUpdateDefaults = ( _walletAddress: string, contractConfig: Config, govTokenDecimals: number @@ -81,40 +88,38 @@ export const DAOConfigUpdateDefaults = ( } } -export const DAOUpdateConfigComponent = ({ - contractAddress, +export const DAOUpdateConfigComponent: TemplateComponent = ({ getLabel, onRemove, errors, - multisig, -}: { - contractAddress: string - getLabel: (field: string) => string - onRemove: () => void - errors: FieldErrors - multisig?: boolean + readOnly, }) => { const { register, setValue, watch } = useFormContext() + const quorum = watch(getLabel('quorum')) const defaultQuorum = watch(getLabel('defaultQuorum')) const [votingPeriodSeconds, setVotingPeriodSeconds] = useState( DEFAULT_MAX_VOTING_PERIOD_SECONDS ) const [thresholdMode, setThresholdMode] = useState( - ThresholdMode.ThresholdQuorum + quorum !== undefined + ? ThresholdMode.ThresholdQuorum + : ThresholdMode.Threshold ) return ( -
+

🎭

Update Config

- + {onRemove && ( + + )}
@@ -122,10 +127,11 @@ export const DAOUpdateConfigComponent = ({ - +
@@ -133,10 +139,11 @@ export const DAOUpdateConfigComponent = ({ - +
@@ -144,17 +151,18 @@ export const DAOUpdateConfigComponent = ({ - +
@@ -191,24 +204,26 @@ export const DAOUpdateConfigComponent = ({ - +
- +
) : ( @@ -218,12 +233,13 @@ export const DAOUpdateConfigComponent = ({ - +
)} @@ -234,12 +250,13 @@ export const DAOUpdateConfigComponent = ({ setVotingPeriodSeconds(e?.target?.value)} defaultValue={DEFAULT_MAX_VOTING_PERIOD_SECONDS} + disabled={readOnly} /> - +
- +
@@ -269,21 +287,20 @@ export const DAOUpdateConfigComponent = ({ - +
-
+
-

- This message will change the configuration of your DAO. Take Care. -

+

This will change the configuration of your DAO. Take Care.

) } -export const transformDAOToConfigUpdateCosmos = ( +export const transformDAOConfigUpdateToCosmos = ( self: DAOConfigUpdateData, props: ToCosmosMsgProps ) => { @@ -326,3 +343,49 @@ export const transformDAOToConfigUpdateCosmos = ( }) return message } + +export const transformCosmosToDAOConfigUpdate = ( + msg: Record, + { govDecimals }: FromCosmosMsgProps +): DAOConfigUpdateData | null => { + if ( + !( + 'wasm' in msg && + 'execute' in msg.wasm && + 'update_config' in msg.wasm.execute.msg && + 'threshold' in msg.wasm.execute.msg.update_config && + ('threshold_quorum' in msg.wasm.execute.msg.update_config.threshold || + 'absolute_percentage' in msg.wasm.execute.msg.update_config.threshold) + ) + ) + return null + + const data = msg.wasm.execute.msg.update_config + + const threshold = ( + parseFloat( + 'threshold_quorum' in data.threshold + ? data.threshold.threshold_quorum.threshold + : data.threshold.absolute_percentage.percentage + ) * 100 + ).toString() + const quorum = + 'threshold_quorum' in data.threshold + ? (parseFloat(data.threshold.threshold_quorum.quorum) * 100).toString() + : undefined + + return { + name: data.name, + description: data.description, + image_url: data.image_url, + max_voting_period: data.max_voting_period.time, + proposal_deposit: convertMicroDenomToDenomWithDecimals( + data.proposal_deposit, + govDecimals + ), + refund_failed_proposals: data.refund_failed_proposals, + threshold, + quorum, + defaultQuorum: quorum || '33', + } +} diff --git a/apps/dapp/templates/custom.tsx b/apps/dapp/templates/custom.tsx index b11cec544..34bfa4130 100644 --- a/apps/dapp/templates/custom.tsx +++ b/apps/dapp/templates/custom.tsx @@ -1,12 +1,13 @@ -import JSON5 from 'json5' -import { CheckIcon, XIcon } from '@heroicons/react/outline' -import { walletAddress } from 'selectors/cosm' +import { Config } from 'util/contractConfigWrapper' import { makeWasmMessage } from 'util/messagehelpers' import { validateCosmosMsg } from 'util/validateWasmMsg' + import { CodeMirrorInput } from '@components/input/CodeMirrorInput' -import { FieldErrors, useFormContext } from 'react-hook-form' -import { ToCosmosMsgProps } from './templateList' -import { Config } from 'util/contractConfigWrapper' +import { CheckIcon, XIcon } from '@heroicons/react/outline' +import JSON5 from 'json5' +import { useFormContext } from 'react-hook-form' + +import { TemplateComponent, ToCosmosMsgProps } from './templateList' export interface CustomData { message: string @@ -15,24 +16,15 @@ export interface CustomData { export const customDefaults = ( _walletAddress: string, _contractConfig: Config -) => { - return { - message: '{}', - } -} +): CustomData => ({ + message: '{}', +}) -export const CustomComponent = ({ - contractAddress, +export const CustomComponent: TemplateComponent = ({ getLabel, onRemove, errors, - multisig, -}: { - contractAddress: string - getLabel: (field: string) => string - onRemove: () => void - errors: FieldErrors - multisig?: boolean + readOnly, }) => { const { control } = useFormContext() @@ -41,20 +33,22 @@ export const CustomComponent = ({ // that we are in a nested object nor wrapped nicely like we do // with register. return ( -
+

🤖

Custom

- + {onRemove && ( + + )}
{ let msg @@ -72,12 +66,13 @@ export const CustomComponent = ({ } }, ]} + readOnly={readOnly} />
- {errors.message ? ( + {errors?.message ? (

{' '} - {errors.message.message === 'Invalid cosmos message' ? ( + {errors?.message.message === 'Invalid cosmos message' ? ( <> Invalid{' '} +): CustomData => ({ + message: JSON.stringify(msg, undefined, 2), +}) diff --git a/apps/dapp/templates/mint.tsx b/apps/dapp/templates/mint.tsx index 8e64c7285..cfdcac0c9 100644 --- a/apps/dapp/templates/mint.tsx +++ b/apps/dapp/templates/mint.tsx @@ -1,22 +1,32 @@ -import { AddressInput } from '@components/input/AddressInput' -import { InputErrorMessage } from '@components/input/InputErrorMessage' -import { NumberInput } from '@components/input/NumberInput' -import { ArrowRightIcon, XIcon } from '@heroicons/react/outline' -import { FieldErrors, useFormContext } from 'react-hook-form' import { useRecoilValue } from 'recoil' + import { Config, contractConfigSelector, ContractConfigWrapper, } from 'util/contractConfigWrapper' -import { convertDenomToMicroDenomWithDecimals } from 'util/conversion' +import { + convertDenomToMicroDenomWithDecimals, + convertMicroDenomToDenomWithDecimals, +} from 'util/conversion' import { validateAddress, validatePositive, validateRequired, } from 'util/formValidation' import { makeExecutableMintMessage, makeMintMessage } from 'util/messagehelpers' -import { ToCosmosMsgProps } from './templateList' + +import { AddressInput } from '@components/input/AddressInput' +import { InputErrorMessage } from '@components/input/InputErrorMessage' +import { NumberInput } from '@components/input/NumberInput' +import { ArrowRightIcon, XIcon } from '@heroicons/react/outline' +import { useFormContext } from 'react-hook-form' + +import { + FromCosmosMsgProps, + TemplateComponent, + ToCosmosMsgProps, +} from './templateList' export interface MintData { to: string @@ -26,25 +36,18 @@ export interface MintData { export const mintDefaults = ( walletAddress: string, _contractConfig: Config -) => { - return { - to: walletAddress, - amount: 1, - } -} +): MintData => ({ + to: walletAddress, + amount: 1, +}) -export const MintComponent = ({ +export const MintComponent: TemplateComponent = ({ contractAddress, getLabel, onRemove, errors, multisig, -}: { - contractAddress: string - getLabel: (field: string) => string - onRemove: () => void - errors: FieldErrors - multisig?: boolean + readOnly, }) => { const { register } = useFormContext() @@ -55,7 +58,7 @@ export const MintComponent = ({ const govTokenDenom = config.gov_token_symbol return ( -

) } @@ -110,3 +117,21 @@ export const transformMintToCosmos = ( props.govAddress ) } + +export const transformCosmosToMint = ( + msg: Record, + { govDecimals }: FromCosmosMsgProps +): MintData | null => + 'wasm' in msg && + 'execute' in msg.wasm && + 'mint' in msg.wasm.execute.msg && + 'amount' in msg.wasm.execute.msg.mint && + 'recipient' in msg.wasm.execute.msg.mint + ? { + to: msg.wasm.execute.msg.mint.recipient, + amount: convertMicroDenomToDenomWithDecimals( + msg.wasm.execute.msg.mint.amount, + govDecimals + ), + } + : null diff --git a/apps/dapp/templates/removeToken.tsx b/apps/dapp/templates/removeToken.tsx index c2e5f6722..78e92c67c 100644 --- a/apps/dapp/templates/removeToken.tsx +++ b/apps/dapp/templates/removeToken.tsx @@ -1,16 +1,18 @@ +import { useRecoilValue, waitForAll } from 'recoil' + +import { Config } from 'util/contractConfigWrapper' +import { validateContractAddress, validateRequired } from 'util/formValidation' +import { makeWasmMessage } from 'util/messagehelpers' + import { AddressInput } from '@components/input/AddressInput' import { InputErrorMessage } from '@components/input/InputErrorMessage' import { InputLabel } from '@components/input/InputLabel' -import { TokenInfoResponse } from '@dao-dao/types/contracts/cw20-gov' import { XIcon } from '@heroicons/react/outline' -import { FieldErrors, useFormContext } from 'react-hook-form' -import { useRecoilValue, waitForAll } from 'recoil' +import { useFormContext } from 'react-hook-form' import { cw20TokenInfo, cw20TokensList } from 'selectors/treasury' -import { Config } from 'util/contractConfigWrapper' -import { validateContractAddress, validateRequired } from 'util/formValidation' -import { makeWasmMessage } from 'util/messagehelpers' + import { TokenInfoDisplay } from './addToken' -import { ToCosmosMsgProps } from './templateList' +import { TemplateComponent, ToCosmosMsgProps } from './templateList' export interface RemoveTokenData { address: string @@ -19,27 +21,27 @@ export interface RemoveTokenData { export const removeTokenDefaults = ( _walletAddress: string, _contractConfig: Config -) => { - return { - address: '', - } +): RemoveTokenData => ({ + address: '', +}) + +interface AddressSelectorProps { + onSelect: (address: string) => void + selectedAddress: string + options: string[] + readOnly?: boolean } const AddressSelector = ({ onSelect, selectedAddress, options, -}: { - onSelect: (address: string) => void - selectedAddress: string - options: string[] -}) => { + readOnly, +}: AddressSelectorProps) => { const tokenInfo = useRecoilValue( waitForAll(options.map((address) => cw20TokenInfo(address))) ) - console.log(selectedAddress) - const active = (a: string) => a === selectedAddress const getClassName = (a: string) => 'btn btn-sm btn-outline rounded-md font-normal' + @@ -54,6 +56,7 @@ const AddressSelector = ({ onClick={() => onSelect(address)} key={address} type="button" + disabled={readOnly} > ${info.symbol} @@ -63,18 +66,12 @@ const AddressSelector = ({ ) } -export const RemoveTokenComponent = ({ +export const RemoveTokenComponent: TemplateComponent = ({ contractAddress, getLabel, onRemove, errors, - multisig, -}: { - contractAddress: string - getLabel: (field: string) => string - onRemove: () => void - errors: FieldErrors - multisig?: boolean + readOnly, }) => { const { register, watch, setError, clearErrors, setValue } = useFormContext() @@ -85,37 +82,41 @@ export const RemoveTokenComponent = ({ tokens.includes(v) || 'This token is not in the DAO treasury.' return ( -
+

⭕️

Remove Treasury Token

- + {onRemove && ( + + )}
setValue(getLabel('address'), address)} selectedAddress={tokenAddress} options={tokens} + readOnly={readOnly} />
- +
+): RemoveTokenData | null => + 'wasm' in msg && + 'execute' in msg.wasm && + 'update_cw20_token_list' in msg.wasm.execute.msg && + 'to_add' in msg.wasm.execute.msg.update_cw20_token_list && + msg.wasm.execute.msg.update_cw20_token_list.to_add.length === 0 && + 'to_remove' in msg.wasm.execute.msg.update_cw20_token_list && + msg.wasm.execute.msg.update_cw20_token_list.to_remove.length === 1 + ? { + address: msg.wasm.execute.msg.update_cw20_token_list.to_remove[0], + } + : null diff --git a/apps/dapp/templates/spend.tsx b/apps/dapp/templates/spend.tsx index cfe7571b7..610eb6f80 100644 --- a/apps/dapp/templates/spend.tsx +++ b/apps/dapp/templates/spend.tsx @@ -1,26 +1,13 @@ -import { AddressInput } from '@components/input/AddressInput' -import { InputErrorMessage } from '@components/input/InputErrorMessage' -import { NumberInput } from '@components/input/NumberInput' -import { SelectInput } from '@components/input/SelectInput' -import { ArrowRightIcon, XIcon } from '@heroicons/react/outline' -import { FieldErrors, useFormContext } from 'react-hook-form' import { useRecoilValue, waitForAll } from 'recoil' + import { NATIVE_DECIMALS, NATIVE_DENOM } from 'util/constants' import { Config } from 'util/contractConfigWrapper' import { - cw20TokensList, - cw20TokenInfo, - nativeBalance as nativeBalanceSelector, - cw20Balances as cw20BalancesSelector, -} from 'selectors/treasury' -import { - convertDenomToContractReadableDenom, convertDenomToHumanReadableDenom, convertDenomToMicroDenomWithDecimals, convertMicroDenomToDenomWithDecimals, nativeTokenDecimals, nativeTokenLabel, - nativeTokenLogoURI, } from 'util/conversion' import { validateAddress, @@ -28,7 +15,25 @@ import { validateRequired, } from 'util/formValidation' import { makeBankMessage, makeWasmMessage } from 'util/messagehelpers' -import { ToCosmosMsgProps } from './templateList' + +import { AddressInput } from '@components/input/AddressInput' +import { InputErrorMessage } from '@components/input/InputErrorMessage' +import { NumberInput } from '@components/input/NumberInput' +import { SelectInput } from '@components/input/SelectInput' +import { ArrowRightIcon, XIcon } from '@heroicons/react/outline' +import { useFormContext } from 'react-hook-form' +import { + cw20TokensList, + cw20TokenInfo, + nativeBalance as nativeBalanceSelector, + cw20Balances as cw20BalancesSelector, +} from 'selectors/treasury' + +import { + FromCosmosMsgProps, + TemplateComponent, + ToCosmosMsgProps, +} from './templateList' export interface SpendData { to: string @@ -39,28 +44,20 @@ export interface SpendData { export const spendDefaults = ( walletAddress: string, _contractConfig: Config -) => { - return { - to: walletAddress, - amount: 1, - denom: convertDenomToHumanReadableDenom( - process.env.NEXT_PUBLIC_FEE_DENOM as string - ), - } -} +): SpendData => ({ + to: walletAddress, + amount: 1, + denom: convertDenomToHumanReadableDenom( + process.env.NEXT_PUBLIC_FEE_DENOM as string + ), +}) -export const SpendComponent = ({ +export const SpendComponent: TemplateComponent = ({ contractAddress, getLabel, onRemove, errors, - multisig, -}: { - contractAddress: string - getLabel: (field: string) => string - onRemove: () => void - errors: FieldErrors - multisig?: boolean + readOnly, }) => { const { register, watch, clearErrors } = useFormContext() @@ -135,16 +132,16 @@ export const SpendComponent = ({ } return ( -
+

💵

Spend

validatePossibleSpendWrapper(denom, spendAmount), ]} border={false} + disabled={readOnly} > {nativeBalances.map(({ denom }, idx) => { return ( @@ -181,23 +180,26 @@ export const SpendComponent = ({
- - - + + +
- + {onRemove && ( + + )}
) } @@ -231,3 +233,50 @@ export const transformSpendToCosmos = ( }, }) } + +export const transformCosmosToSpend = ( + msg: Record, + { govDecimals }: FromCosmosMsgProps +): SpendData | null => { + if ( + 'bank' in msg && + 'send' in msg.bank && + 'amount' in msg.bank.send && + msg.bank.send.amount.length === 1 && + 'amount' in msg.bank.send.amount[0] && + 'denom' in msg.bank.send.amount[0] && + 'to_address' in msg.bank.send + ) { + const denom = msg.bank.send.amount[0].denom + if (denom === NATIVE_DENOM || denom.startsWith('ibc/')) { + return { + to: msg.bank.send.to_address, + amount: convertMicroDenomToDenomWithDecimals( + msg.bank.send.amount[0].amount, + nativeTokenDecimals(denom)! + ), + denom, + } + } + } + + if ( + 'wasm' in msg && + 'execute' in msg.wasm && + 'contract_addr' in msg.wasm.execute && + 'transfer' in msg.wasm.execute.msg && + 'recipient' in msg.wasm.execute.msg.transfer && + 'amount' in msg.wasm.execute.msg.transfer + ) { + return { + to: msg.wasm.execute.msg.transfer.recipient, + amount: convertMicroDenomToDenomWithDecimals( + msg.wasm.execute.msg.transfer.amount, + govDecimals + ), + denom: msg.wasm.execute.contract_addr, + } + } + + return null +} diff --git a/apps/dapp/templates/stake.tsx b/apps/dapp/templates/stake.tsx index 0a7dbc766..45bc4897a 100644 --- a/apps/dapp/templates/stake.tsx +++ b/apps/dapp/templates/stake.tsx @@ -1,13 +1,7 @@ -import { AddressInput } from '@components/input/AddressInput' -import { InputErrorMessage } from '@components/input/InputErrorMessage' -import { NumberInput } from '@components/input/NumberInput' -import { SelectInput } from '@components/input/SelectInput' -import { XIcon } from '@heroicons/react/outline' -import { FieldErrors, useFormContext } from 'react-hook-form' import { useRecoilValue } from 'recoil' + import { NATIVE_DECIMALS, NATIVE_DENOM } from 'util/constants' import { Config } from 'util/contractConfigWrapper' -import { nativeBalance as nativeBalanceSelector } from 'selectors/treasury' import { convertDenomToHumanReadableDenom, convertDenomToMicroDenomWithDecimals, @@ -21,7 +15,16 @@ import { validateRequired, } from 'util/formValidation' import { makeStakingMessage, makeDistributeMessage } from 'util/messagehelpers' -import { ToCosmosMsgProps } from './templateList' + +import { AddressInput } from '@components/input/AddressInput' +import { InputErrorMessage } from '@components/input/InputErrorMessage' +import { NumberInput } from '@components/input/NumberInput' +import { SelectInput } from '@components/input/SelectInput' +import { InformationCircleIcon, XIcon } from '@heroicons/react/outline' +import { useFormContext } from 'react-hook-form' +import { nativeBalance as nativeBalanceSelector } from 'selectors/treasury' + +import { TemplateComponent, ToCosmosMsgProps } from './templateList' export const stakeActions = [ { @@ -53,7 +56,7 @@ export interface StakeData { export const stakeDefaults = ( walletAddress: string, _contractConfig: Config -) => { +): StakeData => { const denom = convertDenomToHumanReadableDenom( process.env.NEXT_PUBLIC_FEE_DENOM as string ) @@ -66,25 +69,17 @@ export const stakeDefaults = ( } } -export const StakeComponent = ({ +export const StakeComponent: TemplateComponent = ({ contractAddress, getLabel, onRemove, errors, - multisig, -}: { - contractAddress: string - getLabel: (field: string) => string - onRemove: () => void - errors: FieldErrors - multisig?: boolean + readOnly, }) => { const { register, watch, clearErrors } = useFormContext() let nativeBalances = useRecoilValue(nativeBalanceSelector(contractAddress)) const stakeType = watch(getLabel('stakeType')) - const validator = watch(getLabel('validator')) - const fromValidator = watch(getLabel('fromValidator')) const amount = watch(getLabel('amount')) const denom = watch(getLabel('denom')) @@ -129,118 +124,125 @@ export const StakeComponent = ({ } return ( -
-
-
-
-

📤

-

Stake

-
+
+
+
+

📤

+

Stake

+
+ {onRemove && ( -
+ )} +
-
- - {stakeActions.map(({ name, type }, idx) => { - return ( - - ) - })} - +
+ + {stakeActions.map(({ name, type }, idx) => ( + + ))} + - {stakeType != 'withdraw_delegator_reward' && ( - <> - - validatePossibleSpendWrapper(denom, amount), - ]} - step={0.000001} - border={false} - /> + {stakeType != 'withdraw_delegator_reward' && ( + <> + validatePossibleSpendWrapper(denom, amount), + ]} + step={0.000001} + border={false} + disabled={readOnly} + /> - - validatePossibleSpendWrapper(denom, amount), - ]} - border={false} - > - {nativeBalances.length !== 0 ? ( - nativeBalances.map(({ denom }, idx) => { - return ( - - ) - }) - ) : ( -
+ )) + ) : ( + + )} + + + )} +
-
- -
+
+ +
- {stakeType == 'redelegate' && ( - <> -

From Validator

-
- -
+ {stakeType == 'redelegate' && ( + <> +

From Validator

+
+ +
-
- -
- - )} +
+ +
+ + )} -

- {stakeType == 'redelegate' ? 'To Validator' : 'Validator'} -

-
- -
+

+ {stakeType == 'redelegate' ? 'To Validator' : 'Validator'} +

+
+ +
-
- -
+
+ +
+ +
+ +

+ This template is new and in beta. Double check the generated JSON + before executing. +

) @@ -254,7 +256,7 @@ export const transformStakeToCosmos = ( return makeDistributeMessage(self.validator) } - // NOTE: Does not support TOKEN staking at this point, hwoever it could be implemented here! + // NOTE: Does not support TOKEN staking at this point, however it could be implemented here! const decimals = nativeTokenDecimals(self.denom)! const amount = convertDenomToMicroDenomWithDecimals(self.amount, decimals) return makeStakingMessage( @@ -265,3 +267,58 @@ export const transformStakeToCosmos = ( self.fromValidator ) } + +export const transformCosmosToStake = ( + msg: Record +): StakeData | null => { + const denom = convertDenomToHumanReadableDenom( + process.env.NEXT_PUBLIC_FEE_DENOM as string + ) + + if ( + 'distribution' in msg && + 'withdraw_delegator_reward' in msg.distribution && + 'validator' in msg.distribution.withdraw_delegator_reward + ) { + return { + stakeType: 'withdraw_delegator_reward', + validator: msg.distribution.withdraw_delegator_reward.validator, + // Default values, not needed for displaying this type of message. + amount: 1, + denom, + } + } else if ('staking' in msg) { + const stakingType = stakeActions + .map(({ type }) => type) + .find((type) => type in msg.staking) + if (!stakingType) return null + + const data = msg.staking[stakingType] + if ( + ((stakingType === 'redelegate' && + 'src_validator' in data && + 'dst_validator' in data) || + (stakingType !== 'redelegate' && 'validator' in data)) && + 'amount' in data && + 'amount' in data.amount && + 'denom' in data.amount + ) { + const { denom } = data.amount + + return { + stakeType: stakingType, + validator: + stakingType === 'redelegate' ? data.dst_validator : data.validator, + fromValidator: + stakingType === 'redelegate' ? data.src_validator : undefined, + amount: convertMicroDenomToDenomWithDecimals( + data.amount.amount, + nativeTokenDecimals(denom)! + ), + denom, + } + } + } + + return null +} diff --git a/apps/dapp/templates/templateList.tsx b/apps/dapp/templates/templateList.tsx index 656c6923d..a4dcaef9e 100644 --- a/apps/dapp/templates/templateList.tsx +++ b/apps/dapp/templates/templateList.tsx @@ -1,34 +1,56 @@ +import { Config } from 'util/contractConfigWrapper' + import { CosmosMsgFor_Empty } from '@dao-dao/types/contracts/cw3-dao' import { FieldErrors } from 'react-hook-form' -import { Config } from 'util/contractConfigWrapper' + import { AddTokenComponent, addTokenDefaults, transformAddTokenToCosmos, + transformCosmosToAddToken, } from './addToken' import { ChangeMembersComponent, changeMembersDefaults, transformChangeMembersToCosmos, + transformCosmosToChangeMembers, } from './changeMembers' import { - DAOConfigUpdateDefaults, + daoConfigUpdateDefaults, DAOUpdateConfigComponent, - transformDAOToConfigUpdateCosmos, + transformCosmosToDAOConfigUpdate, + transformDAOConfigUpdateToCosmos, } from './configUpdate' import { CustomComponent, customDefaults, + transformCosmosToCustom, transformCustomToCosmos, } from './custom' -import { MintComponent, mintDefaults, transformMintToCosmos } from './mint' +import { + MintComponent, + mintDefaults, + transformCosmosToMint, + transformMintToCosmos, +} from './mint' import { RemoveTokenComponent, removeTokenDefaults, + transformCosmosToRemoveToken, transformRemoveTokenToCosmos, } from './removeToken' -import { SpendComponent, spendDefaults, transformSpendToCosmos } from './spend' -import { StakeComponent, stakeDefaults, transformStakeToCosmos } from './stake' +import { + SpendComponent, + spendDefaults, + transformCosmosToSpend, + transformSpendToCosmos, +} from './spend' +import { + StakeComponent, + stakeDefaults, + transformCosmosToStake, + transformStakeToCosmos, +} from './stake' export enum ContractSupport { Multisig, @@ -36,7 +58,7 @@ export enum ContractSupport { Both, } -// Adding a template to this list will cause it to be avaliable +// Adding a template to this list will cause it to be available // across the UI. export const messageTemplates: MessageTemplate[] = [ { @@ -45,6 +67,7 @@ export const messageTemplates: MessageTemplate[] = [ contractSupport: ContractSupport.Both, getDefaults: spendDefaults, toCosmosMsg: transformSpendToCosmos, + fromCosmosMsg: transformCosmosToSpend, }, { label: '🍵 Mint', @@ -52,6 +75,7 @@ export const messageTemplates: MessageTemplate[] = [ contractSupport: ContractSupport.DAO, getDefaults: mintDefaults, toCosmosMsg: transformMintToCosmos, + fromCosmosMsg: transformCosmosToMint, }, { label: '📤 Staking', @@ -59,6 +83,7 @@ export const messageTemplates: MessageTemplate[] = [ contractSupport: ContractSupport.Both, getDefaults: stakeDefaults, toCosmosMsg: transformStakeToCosmos, + fromCosmosMsg: transformCosmosToStake, }, { label: '🤖 Custom', @@ -66,13 +91,15 @@ export const messageTemplates: MessageTemplate[] = [ contractSupport: ContractSupport.Both, getDefaults: customDefaults, toCosmosMsg: transformCustomToCosmos, + fromCosmosMsg: transformCosmosToCustom, }, { label: '🎭 Update Config', component: DAOUpdateConfigComponent, contractSupport: ContractSupport.DAO, - getDefaults: DAOConfigUpdateDefaults, - toCosmosMsg: transformDAOToConfigUpdateCosmos, + getDefaults: daoConfigUpdateDefaults, + toCosmosMsg: transformDAOConfigUpdateToCosmos, + fromCosmosMsg: transformCosmosToDAOConfigUpdate, }, { label: '🔘 Add Treasury Token', @@ -80,6 +107,7 @@ export const messageTemplates: MessageTemplate[] = [ contractSupport: ContractSupport.Both, getDefaults: addTokenDefaults, toCosmosMsg: transformAddTokenToCosmos, + fromCosmosMsg: transformCosmosToAddToken, }, { label: '⭕️ Remove Treasury Token', @@ -87,6 +115,7 @@ export const messageTemplates: MessageTemplate[] = [ contractSupport: ContractSupport.Both, getDefaults: removeTokenDefaults, toCosmosMsg: transformRemoveTokenToCosmos, + fromCosmosMsg: transformCosmosToRemoveToken, }, { label: '🖋 Manage Members', @@ -94,29 +123,72 @@ export const messageTemplates: MessageTemplate[] = [ contractSupport: ContractSupport.Multisig, getDefaults: changeMembersDefaults, toCosmosMsg: transformChangeMembersToCosmos, + fromCosmosMsg: transformCosmosToChangeMembers, }, ] +// Ensure custom is always sorted last for two reasons: +// 1. It should display last since it is a catch-all. +// 2. It should be the last template type matched against when listing proposals in the UI since it will match any message (see messageTemplateAndValuesForDecodedCosmosMsg). +messageTemplates.sort((a, b) => { + if (a.component === CustomComponent) { + return 1 + } else if (b.component === CustomComponent) { + return -1 + } + return 0 +}) + +export const messageTemplateToCosmosMsg = ( + m: MessageTemplate, + props: ToCosmosMsgProps +): CosmosMsgFor_Empty | undefined => + messageTemplates + .find((template) => template.label === m.label) + ?.toCosmosMsg?.(m as any, props) + +export const messageTemplateAndValuesForDecodedCosmosMsg = ( + msg: Record, + props: FromCosmosMsgProps +) => { + // Ensure custom is the last message template since it will match most proposals and we return the first successful message match. + for (const template of messageTemplates) { + const values = template.fromCosmosMsg(msg, props) + if (values) { + return { + template, + values, + } + } + } + return null +} // A component which will render a template's input form. -export type TemplateComponent = React.FunctionComponent<{ +export interface TemplateComponentProps { contractAddress: string getLabel: (field: string) => string - onRemove: () => void - errors: FieldErrors + onRemove?: () => void + errors?: FieldErrors multisig?: boolean -}> + readOnly?: boolean +} +export type TemplateComponent = React.FunctionComponent // Defines a new template. export interface MessageTemplate { label: string component: TemplateComponent contractSupport: ContractSupport + // Get default for fields in form display. getDefaults: ( walletAddress: string, contractConfig: Config, govTokenDecimals: number ) => any + // Convert MessageTemplate to CosmosMsgFor_Empty. toCosmosMsg: (self: any, props: ToCosmosMsgProps) => CosmosMsgFor_Empty + // Convert decoded msg data to fields in form display. + fromCosmosMsg: (msg: Record, props: FromCosmosMsgProps) => any } // The contextual information provided to templates when transforming @@ -128,6 +200,12 @@ export interface ToCosmosMsgProps { multisig: boolean } +// The contextual information provided to templates when transforming +// from cosmos messages to values. +export interface FromCosmosMsgProps { + govDecimals: number +} + // When template data is being passed around in a form it must carry // a label with it so that we can find it's component and // transformation function later. This type just encodes that. diff --git a/apps/dapp/util/messagehelpers.ts b/apps/dapp/util/messagehelpers.ts index b567aff98..821d953d5 100644 --- a/apps/dapp/util/messagehelpers.ts +++ b/apps/dapp/util/messagehelpers.ts @@ -1,8 +1,7 @@ +import { useRecoilValue } from 'recoil' + import { fromBase64, toBase64, fromAscii, toAscii } from '@cosmjs/encoding' -import { - convertDenomToHumanReadableDenom, - convertDenomToMicroDenomWithDecimals, -} from './conversion' +import { ExecuteMsg as MintExecuteMsg } from '@dao-dao/types/contracts/cw20-gov' import { BankMsg, Coin, @@ -17,21 +16,24 @@ import { Uint128, ProposalResponse, } from '@dao-dao/types/contracts/cw3-dao' -import { ExecuteMsg as MintExecuteMsg } from '@dao-dao/types/contracts/cw20-gov' -import { C4_GROUP_CODE_ID, CW20_CODE_ID, STAKE_CODE_ID } from './constants' import { InstantiateMsg as MultisigInstantiateMsg, Member, } from '@dao-dao/types/contracts/cw3-multisig' import { MintMsg } from 'types/messages' +import { ProposalMapItem } from 'types/proposals' + import { MessageMapEntry, ProposalMessageType, } from '../models/proposal/messageMap' -import { ProposalMapItem } from 'types/proposals' -import { convertDenomToContractReadableDenom } from './conversion' import { cw20TokenInfo } from '../selectors/treasury' -import { useRecoilValue } from 'recoil' +import { C4_GROUP_CODE_ID, CW20_CODE_ID, STAKE_CODE_ID } from './constants' +import { convertDenomToContractReadableDenom } from './conversion' +import { + convertDenomToHumanReadableDenom, + convertDenomToMicroDenomWithDecimals, +} from './conversion' const DENOM = convertDenomToHumanReadableDenom( process.env.NEXT_PUBLIC_STAKING_DENOM || '' @@ -55,6 +57,7 @@ export function makeBankMessage( denom, }, ], + // TODO: What are these type and from_address fields? They don't show up in spend messages after proposals are created. [TYPE_KEY]: BANK_SEND_TYPE, from_address, to_address, @@ -444,10 +447,10 @@ function isBinaryType(msgType?: WasmMsgType): boolean { } export function decodeMessages( - proposal: ProposalResponse + msgs: ProposalResponse['msgs'] ): { [key: string]: any }[] { const decodedMessageArray: any[] = [] - const proposalMsgs = Object.values(proposal.msgs) + const proposalMsgs = Object.values(msgs) for (const msgObj of proposalMsgs) { if (isWasmMsg(msgObj)) { const msgType = getWasmMsgType(msgObj.wasm) @@ -481,8 +484,8 @@ export function decodeMessages( return decodedMessages } -export function decodedMessagesString(proposal: ProposalResponse): string { - const decodedMessageArray = decodeMessages(proposal) +export function decodedMessagesString(msgs: ProposalResponse['msgs']): string { + const decodedMessageArray = decodeMessages(msgs) return JSON.stringify(decodedMessageArray, undefined, 2) } @@ -526,7 +529,7 @@ export function isMintMsg(msg: any): msg is MintMsg { return false } -export function messageForDraftProposal( +export function useMessageForDraftProposal( draftProposal: ProposalMapItem, govTokenAddress?: string ) {