From d6b936166d714023278ea07bff81e1e323508326 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Mon, 10 Jul 2023 22:57:01 +0200 Subject: [PATCH 01/20] Started adding gov prop action. --- apps/dapp/package.json | 2 +- apps/sda/package.json | 2 +- packages/i18n/locales/en/translation.json | 14 + packages/state/package.json | 2 +- packages/state/recoil/selectors/chain.ts | 39 +- .../GovernanceProposal/Component.stories.tsx | 58 +++ .../GovernanceProposal/Component.tsx | 345 ++++++++++++++++++ .../GovernanceProposal/README.md | 37 ++ .../GovernanceProposal/index.tsx | 260 +++++++++++++ .../GovernanceVote/Component.stories.tsx | 4 + .../GovernanceVote/Component.tsx | 153 ++------ .../chain_governance/GovernanceVote/index.tsx | 4 + .../actions/core/chain_governance/index.ts | 7 +- .../stateful/components/PayEntityDisplay.tsx | 41 +++ .../components/TokenAmountDisplay.tsx | 47 +++ packages/stateful/components/index.ts | 2 + packages/stateful/package.json | 2 +- .../stateless/components/PayEntityDisplay.tsx | 61 ++++ packages/stateless/components/emoji.tsx | 4 + packages/stateless/components/index.tsx | 1 + .../components/inputs/TokenInput.tsx | 4 +- .../proposal/GovernanceProposal.tsx | 200 ++++++++++ .../stateless/components/proposal/index.tsx | 1 + .../components/token/TokenAmountDisplay.tsx | 48 +-- packages/types/actions.ts | 1 + packages/types/package.json | 2 +- packages/types/state.ts | 18 +- .../stateless/{KadoModal.tsx => KadoModal.ts} | 0 .../types/stateless/{Modal.tsx => Modal.ts} | 0 packages/types/stateless/PayEntityDisplay.ts | 23 ++ packages/types/stateless/{Row.tsx => Row.ts} | 0 .../types/stateless/TokenAmountDisplay.ts | 63 ++++ packages/types/stateless/index.ts | 2 + packages/types/tsconfig.json | 2 +- packages/utils/messages/protobuf.ts | 46 ++- packages/utils/package.json | 2 +- yarn.lock | 2 +- 37 files changed, 1303 insertions(+), 196 deletions(-) create mode 100644 packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.stories.tsx create mode 100644 packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx create mode 100644 packages/stateful/actions/core/chain_governance/GovernanceProposal/README.md create mode 100644 packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx create mode 100644 packages/stateful/components/PayEntityDisplay.tsx create mode 100644 packages/stateful/components/TokenAmountDisplay.tsx create mode 100644 packages/stateless/components/PayEntityDisplay.tsx create mode 100644 packages/stateless/components/proposal/GovernanceProposal.tsx rename packages/types/stateless/{KadoModal.tsx => KadoModal.ts} (100%) rename packages/types/stateless/{Modal.tsx => Modal.ts} (100%) create mode 100644 packages/types/stateless/PayEntityDisplay.ts rename packages/types/stateless/{Row.tsx => Row.ts} (100%) create mode 100644 packages/types/stateless/TokenAmountDisplay.ts diff --git a/apps/dapp/package.json b/apps/dapp/package.json index ddf9ff0984..a359009127 100644 --- a/apps/dapp/package.json +++ b/apps/dapp/package.json @@ -36,7 +36,7 @@ "@types/formidable": "^2.0.5", "cors": "^2.8.5", "formidable": "^2.0.1", - "interchain-rpc": "1.5.0", + "interchain-rpc": "^1.5.0", "lodash.clonedeep": "^4.5.0", "next": "^13.3.0", "next-intercept-stdout": "^1.0.1", diff --git a/apps/sda/package.json b/apps/sda/package.json index 0b6d04aeb8..c2726f48ff 100644 --- a/apps/sda/package.json +++ b/apps/sda/package.json @@ -35,7 +35,7 @@ "@types/formidable": "^2.0.5", "cors": "^2.8.5", "formidable": "^2.0.1", - "interchain-rpc": "1.5.0", + "interchain-rpc": "^1.5.0", "lodash.clonedeep": "^4.5.0", "next": "^13.3.0", "next-intercept-stdout": "^1.0.1", diff --git a/packages/i18n/locales/en/translation.json b/packages/i18n/locales/en/translation.json index 3eb2d12129..4e5cf286a4 100644 --- a/packages/i18n/locales/en/translation.json +++ b/packages/i18n/locales/en/translation.json @@ -236,6 +236,7 @@ "pencil": "Pencil", "people": "People", "pick": "Mining pick", + "raisedHand": "Raised hand", "recycle": "Recycle", "robot": "Robot", "suitAndTie": "Suit and tie", @@ -438,6 +439,7 @@ "iContributedPlaceholder": "I contributed...", "image": "Image", "imageUrlTooltip": "A link to an image. For example: https://moonphase.is/image.svg", + "initialDeposit": "Initial deposit", "initialSupply": "Total token supply", "instantiatedAddress": "Instantiated address", "instructions": "Instructions", @@ -473,6 +475,7 @@ "options": "Options", "orUploadOne": "...or upload one below.", "outputToken": "Output token", + "parameterChanges": "Parameter changes", "passingThresholdDescription": "Passing threshold works differently depending on whether your DAO has a quorum.\n- If your DAO has *no quorum*, this is the percentage of the DAO's voting power that must vote 'yes' for a proposal to pass.\n- If your DAO *has a quorum*, the passing threshold is only calculated from those who voted.", "passingThresholdTitle": "Passing threshold", "passingThresholdWithQuorumDescription": "The proportion of those who voted on a single choice proposal who must vote 'Yes' for it to pass.", @@ -485,6 +488,7 @@ "proposalDepositTitle": "Proposal deposit", "proposalSubmissionPolicyDescription": "Who is allowed to submit proposals to the DAO?", "proposalSubmissionPolicyTitle": "Proposal submission policy", + "proposalType": "Proposal type", "proposalsDescriptionPlaceholder": "Give your proposal a description...", "proposalsName": "Proposal's name", "proposalsNamePlaceholder": "Give your proposal a name", @@ -577,11 +581,19 @@ "depositPeriod": "Deposit Period", "failed": "Failed", "passed": "Passed", + "pendingSubmission": "Pending Submission", "rejected": "Rejected", "unrecognized": "Unrecognized", "unspecified": "Unspecified", "votingPeriod": "Voting Period" }, + "govProposalType": { + "CancelSoftwareUpgradeProposal": "Cancel Software Upgrade", + "CommunityPoolSpendProposal": "Community Pool Spend", + "ParameterChangeProposal": "Parameter Change", + "SoftwareUpgradeProposal": "Software Upgrade", + "TextProposal": "Text" + }, "info": { "abstainVote": "Abstain", "actionPageWarning": "There are {{actions}} actions across {{pages}} pages. Make sure you view all pages before voting.", @@ -841,6 +853,7 @@ "subjectsCurrentlyCastVoteTooltip": "This is the vote that {{subject}} already cast. Casting another vote will replace this one.", "subjectsVote": "{{subject}} vote", "submissionsBeingRated": "Members of the DAO are rating submissions. After the rating period closes, the DAO will publish a proposal to distribute compensation according to their responses.", + "submitGovernanceProposalDescription": "Submit a proposal to chain governance.", "submitUpgradeProposal": "Submit a proposal to upgrade the DAO?", "submitted": "Submitted", "supportsMarkdownFormat": "Supports Markdown format", @@ -1214,6 +1227,7 @@ "start": "Start", "status": "Status", "subDaos": "SubDAOs", + "submitGovernanceProposal": "Submit Governance Proposal", "suggestions": "Suggestions", "summary": "Summary", "supplies": "Supplies", diff --git a/packages/state/package.json b/packages/state/package.json index ed76130f84..d6fc479ca3 100644 --- a/packages/state/package.json +++ b/packages/state/package.json @@ -16,7 +16,7 @@ "@dao-dao/utils": "2.2.0", "cosmjs-types": "^0.5.2", "graphql": "^16.6.0", - "interchain-rpc": "1.5.0", + "interchain-rpc": "^1.5.0", "json5": "^2.2.0", "meilisearch": "^0.30.0", "pusher-js": "^7.6.0", diff --git a/packages/state/recoil/selectors/chain.ts b/packages/state/recoil/selectors/chain.ts index f2dd687c0a..2411eb54d6 100644 --- a/packages/state/recoil/selectors/chain.ts +++ b/packages/state/recoil/selectors/chain.ts @@ -15,6 +15,7 @@ import { Proposal as GovProposal, WeightedVoteOption, } from 'interchain-rpc/types/codegen/cosmos/gov/v1beta1/gov' +import { QueryParamsResponse as GovQueryParamsResponse } from 'interchain-rpc/types/codegen/cosmos/gov/v1beta1/query' import { DelegationResponse, UnbondingDelegation as RpcUnbondingDelegation, @@ -43,7 +44,7 @@ import { NATIVE_TOKEN, cosmWasmClientRouter, cosmosValidatorToValidator, - decodeGovProposalContent, + decodeGovProposal, getAllRpcResponse, getRpcForChainId, isJunoIbcUsdc, @@ -412,7 +413,7 @@ export const govProposalsSelector = selectorFamily< } return proposals - .map((proposal) => decodeGovProposalContent(proposal)) + .map((proposal) => decodeGovProposal(proposal)) .sort((a, b) => a.votingEndTime.getTime() - b.votingEndTime.getTime()) }, }) @@ -434,7 +435,7 @@ export const govProposalSelector = selectorFamily< }) )?.proposal - return proposal && decodeGovProposalContent(proposal) + return proposal && decodeGovProposal(proposal) }, }) @@ -458,6 +459,38 @@ export const govProposalVoteSelector = selectorFamily< }, }) +// Queries the chain for the minimum deposit required. +export const govQueryParamsSelector = selectorFamily< + Required, + WithChainId<{}> +>({ + key: 'govQueryParams', + get: + ({ chainId }) => + async ({ get }) => { + const client = get(cosmosRpcClientForChainSelector(chainId)) + + const [{ votingParams }, { depositParams }, { tallyParams }] = + await Promise.all([ + client.gov.v1beta1.params({ + paramsType: 'voting', + }), + client.gov.v1beta1.params({ + paramsType: 'deposit', + }), + client.gov.v1beta1.params({ + paramsType: 'tallying', + }), + ]) + + return { + votingParams, + depositParams, + tallyParams, + } + }, +}) + export const validatorsSelector = selectorFamily>({ key: 'validators', get: diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.stories.tsx b/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.stories.tsx new file mode 100644 index 0000000000..c389e94168 --- /dev/null +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.stories.tsx @@ -0,0 +1,58 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react' + +import { ReactHookFormDecorator } from '@dao-dao/storybook' +import { GovernanceProposalType } from '@dao-dao/types' + +import { PayEntityDisplay } from '../../../../components/PayEntityDisplay' +import { TokenAmountDisplay } from '../../../../components/TokenAmountDisplay' +import { GovernanceProposalComponent } from './Component' + +export default { + title: + 'DAO DAO / packages / stateful / actions / core / chain_governance / GovernanceProposal', + component: GovernanceProposalComponent, + decorators: [ReactHookFormDecorator], +} as ComponentMeta + +const Template: ComponentStory = (args) => ( + +) + +export const Default = Template.bind({}) +Default.args = { + fieldNamePrefix: '', + allActionsWithData: [], + index: 0, + data: { + type: GovernanceProposalType.SoftwareUpgradeProposal, + title: 'Upgrade to v10 Alpha 1', + description: + 'Full details on the testnets github. Target binary is v10.0.0-alpha.2', + deposit: [ + { + amount: '500000000', + denom: 'ujunox', + }, + ], + amount: [], + parameterChanges: JSON.stringify([]), + upgradePlan: JSON.stringify( + { + name: 'v10', + time: '0001-01-01T00:00:00Z', + height: '20000', + info: '', + upgraded_client_state: null, + }, + null, + 2 + ), + }, + isCreating: true, + errors: {}, + options: { + minDeposits: { loading: false, data: [] }, + PayEntityDisplay, + TokenAmountDisplay, + }, +} diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx b/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx new file mode 100644 index 0000000000..f7fb36e0ed --- /dev/null +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx @@ -0,0 +1,345 @@ +import { Coin } from '@cosmjs/amino' +import { Check, Close } from '@mui/icons-material' +import { ComponentType } from 'react' +import { useFieldArray, useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' + +import { + Button, + CodeMirrorInput, + GovernanceProposal, + IconButton, + InputErrorMessage, + InputLabel, + SelectInput, + TextAreaInput, + TextInput, + TokenInput, +} from '@dao-dao/stateless' +import { + GenericToken, + GenericTokenBalance, + GovernanceProposalType, + LoadingData, + StatefulPayEntityDisplayProps, + StatefulTokenAmountDisplayProps, +} from '@dao-dao/types' +import { ActionComponent } from '@dao-dao/types/actions' +import { + NATIVE_TOKEN, + convertDenomToMicroDenomWithDecimals, + convertMicroDenomToDenomWithDecimals, + ibcAssets, + validateJSON, + validateRequired, +} from '@dao-dao/utils' + +import { useActionOptions } from '../../../react/context' + +export type GovernanceProposalOptions = { + minDeposits: LoadingData + PayEntityDisplay: ComponentType + TokenAmountDisplay: ComponentType +} + +export type GovernanceProposalData = { + type: GovernanceProposalType + title: string + description: string + deposit: Coin[] + // GovernanceProposalType.CommunityPoolSpendProposal + amount: Coin[] + // GovernanceProposalType.ParameterChangeProposal + parameterChanges: string + // GovernanceProposalType.SoftwareUpgradeProposal + upgradePlan: string +} + +export const GovernanceProposalComponent: ActionComponent< + GovernanceProposalOptions, + GovernanceProposalData +> = ({ + fieldNamePrefix, + errors, + isCreating, + options: { minDeposits, PayEntityDisplay, TokenAmountDisplay }, + data, +}) => { + const { address } = useActionOptions() + const { t } = useTranslation() + const { register, setValue, watch, control } = + useFormContext() + + const selectedMinDepositToken = minDeposits.loading + ? undefined + : minDeposits.data.find( + ({ token }) => token.denomOrAddress === data.deposit[0].denom + ) + + const { + fields: amountFields, + append: appendCoin, + remove: removeCoin, + } = useFieldArray({ + control, + name: (fieldNamePrefix + 'amount') as 'amount', + }) + + const availableTokens: GenericToken[] = [ + // First native. + { + ...NATIVE_TOKEN, + }, + // Then the IBC assets. + ...ibcAssets, + ] + + return ( + <> + {isCreating ? ( + <> +
+ + + + {Object.entries(GovernanceProposalType).map(([name, type]) => ( + + ))} + +
+ +
+ + + +
+ +
+ + {t('form.description')} + + {/* eslint-disable-next-line i18next/no-literal-string */} + {' – '} + {t('info.supportsMarkdownFormat')} + + + + +
+ +
+ + + setValue( + (fieldNamePrefix + 'deposit.0.denom') as 'deposit.0.denom', + denomOrAddress + ) + } + readOnly={!isCreating} + register={register} + selectedToken={selectedMinDepositToken?.token} + setValue={setValue} + tokens={ + minDeposits.loading + ? { loading: true } + : { + loading: false, + data: minDeposits.data.map(({ token }) => token), + } + } + watch={watch} + /> +
+ + {data.type === GovernanceProposalType.CommunityPoolSpendProposal && ( +
+ + +
+
+
+ {amountFields.map(({ id, denom }, index) => { + const selectedToken = availableTokens.find( + ({ denomOrAddress }) => denomOrAddress === denom + ) + if (!selectedToken) { + return null + } + + return ( +
+ + setValue( + (fieldNamePrefix + + `amount.${index}.denom`) as `amount.${number}.denom`, + denomOrAddress + ) + } + register={register} + selectedToken={selectedToken} + setValue={setValue} + tokens={{ loading: false, data: availableTokens }} + watch={watch} + /> + + removeCoin(index)} + size="sm" + variant="ghost" + /> +
+ ) + })} + + {isCreating && ( + + )} +
+
+
+
+ )} + + {data.type === GovernanceProposalType.ParameterChangeProposal && ( +
+ + + {errors?.parameterChanges?.message ? ( +

+ {' '} + {errors.parameterChanges?.message} +

+ ) : ( +

+ {t('info.jsonIsValid')} +

+ )} +
+ )} + + {data.type === GovernanceProposalType.SoftwareUpgradeProposal && ( +
+ + + {errors?.upgradePlan?.message ? ( +

+ {' '} + {errors.upgradePlan?.message} +

+ ) : ( +

+ {t('info.jsonIsValid')} +

+ )} +
+ )} + + ) : ( + + )} + + ) +} diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/README.md b/packages/stateful/actions/core/chain_governance/GovernanceProposal/README.md new file mode 100644 index 0000000000..8a25227521 --- /dev/null +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/README.md @@ -0,0 +1,37 @@ +# GovernanceProposal + +Submit a chain governance proposal. + +## Bulk import format + +This is relevant when bulk importing actions, as described in [this +guide](https://github.com/DA0-DA0/dao-dao-ui/wiki/Bulk-importing-actions). + +### Key + +`governanceProposal` + +### Data format + +```json +{ + "type": "", + "title": "", + "description": "<DESCRIPTION>", + "initialDeposit": [ + { + "denom": "<DENOM>", + "amount": "<AMOUNT>" + }, + ... + ] +} +``` + +`type` is one of: + +- `/cosmos.gov.v1beta1.TextProposal` +- `/cosmos.distribution.v1beta1.CommunityPoolSpendProposal` +- `/cosmos.params.v1beta1.ParameterChangeProposal` +- `/cosmos.upgrade.v1beta1.SoftwareUpgradeProposal` +- `/cosmos.upgrade.v1beta1.CancelSoftwareUpgradeProposal` diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx b/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx new file mode 100644 index 0000000000..99a062b2e3 --- /dev/null +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx @@ -0,0 +1,260 @@ +import { MsgSubmitProposal } from 'cosmjs-types/cosmos/gov/v1beta1/tx' +import { ParameterChangeProposal } from 'cosmjs-types/cosmos/params/v1beta1/params' +import { SoftwareUpgradeProposal } from 'cosmjs-types/cosmos/upgrade/v1beta1/upgrade' +import { CommunityPoolSpendProposal } from 'interchain-rpc/types/codegen/cosmos/distribution/v1beta1/distribution' +import { TextProposal } from 'interchain-rpc/types/codegen/cosmos/gov/v1beta1/gov' +import { useCallback } from 'react' +import { waitForAll } from 'recoil' + +import { genericTokenSelector, govQueryParamsSelector } from '@dao-dao/state' +import { RaisedHandEmoji, useCachedLoading } from '@dao-dao/stateless' +import { GovernanceProposalType, TokenType } from '@dao-dao/types' +import { + ActionComponent, + ActionKey, + ActionMaker, + UseDecodedCosmosMsg, + UseDefaults, + UseTransformToCosmos, +} from '@dao-dao/types/actions' +import { + decodeGovProposalContent, + encodeRawProtobufMsg, + isDecodedStargateMsg, + makeStargateMessage, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { PayEntityDisplay } from '../../../../components/PayEntityDisplay' +import { TokenAmountDisplay } from '../../../../components/TokenAmountDisplay' +import { useActionOptions } from '../../../react' +import { + GovernanceProposalData, + GovernanceProposalComponent as StatelessGovernanceProposalComponent, +} from './Component' + +const SUBMIT_PROPOSAL_TYPE_URL = '/cosmos.gov.v1beta1.MsgSubmitProposal' + +const Component: ActionComponent<undefined, GovernanceProposalData> = ( + props +) => { + const { chainId } = useActionOptions() + + const govParams = useCachedLoading( + govQueryParamsSelector({ + chainId, + }), + undefined + ) + const minDeposits = useCachedLoading( + govParams.loading || !govParams.data + ? undefined + : waitForAll( + govParams.data.depositParams.minDeposit.map(({ denom }) => + genericTokenSelector({ + type: TokenType.Native, + denomOrAddress: denom, + }) + ) + ), + [] + ) + + return ( + <StatelessGovernanceProposalComponent + {...props} + options={{ + minDeposits: + minDeposits.loading || govParams.loading || !govParams.data + ? { loading: true } + : { + loading: false, + data: minDeposits.data.map((token, index) => ({ + token, + balance: + govParams.data!.depositParams.minDeposit[index].amount, + })), + }, + PayEntityDisplay, + TokenAmountDisplay, + }} + /> + ) +} + +const defaultParameterChanges = JSON.stringify( + [ + { + subspace: 'INSERT', + key: 'INSERT', + value: 'INSERT', + }, + ], + null, + 2 +) +const defaultPlan = JSON.stringify( + { + name: 'INSERT', + height: 'INSERT', + info: 'INSERT', + upgradedClientState: 'INSERT', + }, + null, + 2 +) + +export const makeGovernanceProposalAction: ActionMaker< + GovernanceProposalData +> = ({ t, address, chainId }) => { + const useDefaults: UseDefaults<GovernanceProposalData> = () => { + const govParams = useCachedLoading( + govQueryParamsSelector({ + chainId, + }), + undefined + ) + + return { + type: GovernanceProposalType.TextProposal, + title: '', + description: '', + deposit: + govParams.loading || !govParams.data + ? [] + : [{ ...govParams.data.depositParams.minDeposit[0] }], + amount: [], + parameterChanges: defaultParameterChanges, + upgradePlan: defaultPlan, + } + } + + const useTransformToCosmos: UseTransformToCosmos< + GovernanceProposalData + > = () => + useCallback( + ({ + type, + title, + description, + deposit, + amount, + parameterChanges, + upgradePlan, + }) => { + const content = encodeRawProtobufMsg( + type === GovernanceProposalType.CommunityPoolSpendProposal + ? { + typeUrl: type, + value: { + title, + description, + amount, + recipient: address, + } as CommunityPoolSpendProposal, + } + : type === GovernanceProposalType.ParameterChangeProposal + ? { + typeUrl: type, + value: { + title, + description, + changes: JSON.parse(parameterChanges), + } as ParameterChangeProposal, + } + : type === GovernanceProposalType.SoftwareUpgradeProposal + ? { + typeUrl: type, + value: { + title, + description, + plan: JSON.parse(upgradePlan), + } as SoftwareUpgradeProposal, + } + : // Default to text proposal. + { + typeUrl: type, + value: { + title, + description, + } as TextProposal, + } + ) + + return makeStargateMessage({ + stargate: { + typeUrl: SUBMIT_PROPOSAL_TYPE_URL, + value: { + content, + initialDeposit: deposit, + proposer: address, + } as MsgSubmitProposal, + }, + }) + }, + [] + ) + + const useDecodedCosmosMsg: UseDecodedCosmosMsg<GovernanceProposalData> = ( + msg: Record<string, any> + ) => { + if ( + !isDecodedStargateMsg(msg) || + msg.stargate.typeUrl !== SUBMIT_PROPOSAL_TYPE_URL || + !objectMatchesStructure(msg.stargate.value, { + content: {}, + initialDeposit: {}, + proposer: {}, + }) || + msg.stargate.value.proposer !== address + ) { + return { + match: false, + } + } + + const decodedContent = decodeGovProposalContent(msg.stargate.value.content) + const type = decodedContent.typeUrl as GovernanceProposalType + if (!Object.values(GovernanceProposalType).includes(type)) { + return { + match: false, + } + } + + return { + match: true, + data: { + type, + title: decodedContent.value.title, + description: decodedContent.value.description, + deposit: msg.stargate.value.initialDeposit, + amount: + decodedContent.typeUrl === + GovernanceProposalType.CommunityPoolSpendProposal + ? decodedContent.value.amount + : [], + parameterChanges: + decodedContent.typeUrl === + GovernanceProposalType.ParameterChangeProposal + ? JSON.stringify(decodedContent.value.changes, null, 2) + : defaultParameterChanges, + upgradePlan: + decodedContent.typeUrl === + GovernanceProposalType.SoftwareUpgradeProposal + ? JSON.stringify(decodedContent.value.plan, null, 2) + : defaultPlan, + }, + } + } + + return { + key: ActionKey.GovernanceProposal, + Icon: RaisedHandEmoji, + label: t('title.submitGovernanceProposal'), + description: t('info.submitGovernanceProposalDescription'), + Component, + useDefaults, + useTransformToCosmos, + useDecodedCosmosMsg, + } +} diff --git a/packages/stateful/actions/core/chain_governance/GovernanceVote/Component.stories.tsx b/packages/stateful/actions/core/chain_governance/GovernanceVote/Component.stories.tsx index cf1abbb8ec..99c7f9ec42 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceVote/Component.stories.tsx +++ b/packages/stateful/actions/core/chain_governance/GovernanceVote/Component.stories.tsx @@ -5,6 +5,8 @@ import Long from 'long' import { ReactHookFormDecorator } from '@dao-dao/storybook' import { GovProposalWithDecodedContent } from '@dao-dao/types' +import { PayEntityDisplay } from '../../../../components/PayEntityDisplay' +import { TokenAmountDisplay } from '../../../../components/TokenAmountDisplay' import { GovernanceVoteComponent } from './Component' export default { @@ -65,5 +67,7 @@ Default.args = { errors: {}, options: { proposals: [makeProposal(), makeProposal(), makeProposal(), makeProposal()], + PayEntityDisplay, + TokenAmountDisplay, }, } diff --git a/packages/stateful/actions/core/chain_governance/GovernanceVote/Component.tsx b/packages/stateful/actions/core/chain_governance/GovernanceVote/Component.tsx index e2e9468245..0d1eb1ed08 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceVote/Component.tsx +++ b/packages/stateful/actions/core/chain_governance/GovernanceVote/Component.tsx @@ -1,56 +1,45 @@ import { decodeCosmosSdkDecFromProto } from '@cosmjs/stargate' import { - ArrowOutwardRounded, Block, Check, CheckBoxOutlineBlankRounded, Close, - HourglassTopRounded, - RotateRightOutlined, Texture, - TimelapseRounded, - TimerRounded, } from '@mui/icons-material' -import { ProposalStatus, VoteOption } from 'cosmjs-types/cosmos/gov/v1beta1/gov' +import { VoteOption } from 'cosmjs-types/cosmos/gov/v1beta1/gov' import { WeightedVoteOption } from 'interchain-rpc/types/codegen/cosmos/gov/v1beta1/gov' +import { ComponentType } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' -import TimeAgo from 'react-timeago' import { - IconButtonLink, - MarkdownRenderer, + GovernanceProposal, NoContent, - ProposalStatusAndInfo, - ProposalStatusAndInfoProps, ProposalVoteButton, SelectInput, - Tooltip, TooltipInfoIcon, - useTranslatedTimeDeltaFormatter, } from '@dao-dao/stateless' import { GovProposalWithDecodedContent, LoadingData, ProposalVoteOption, + StatefulPayEntityDisplayProps, + StatefulTokenAmountDisplayProps, } from '@dao-dao/types' import { ActionComponent, ActionComponentProps, ActionContextType, } from '@dao-dao/types/actions' -import { - CHAIN_GOV_PROPOSAL_URL_TEMPLATE, - formatDateTimeTz, - formatPercentOf100, - validateRequired, -} from '@dao-dao/utils' +import { formatPercentOf100, validateRequired } from '@dao-dao/utils' import { useActionOptions } from '../../../react' export interface GovernanceVoteOptions { proposals: GovProposalWithDecodedContent[] existingVotesLoading?: LoadingData<WeightedVoteOption[] | undefined> + PayEntityDisplay: ComponentType<StatefulPayEntityDisplayProps> + TokenAmountDisplay: ComponentType<StatefulTokenAmountDisplayProps> } export interface GovernanceVoteData { @@ -64,7 +53,12 @@ export const GovernanceVoteComponent: ActionComponent< fieldNamePrefix, errors, isCreating, - options: { proposals, existingVotesLoading }, + options: { + proposals, + existingVotesLoading, + PayEntityDisplay, + TokenAmountDisplay, + }, }) => { const { t } = useTranslation() const { register, watch } = useFormContext<GovernanceVoteData>() @@ -75,8 +69,6 @@ export const GovernanceVoteComponent: ActionComponent< (p) => p.proposalId.toString() === proposalId ) - const timeAgoFormatter = useTranslatedTimeDeltaFormatter({ words: false }) - return ( <> {isCreating && @@ -110,101 +102,16 @@ export const GovernanceVoteComponent: ActionComponent< ))} {proposalSelected ? ( - <div className="flex flex-col gap-6"> - <div className="flex flex-row items-center gap-4"> - <p className="header-text"> - #{proposalId}{' '} - {proposalSelected.decodedContent && - 'title' in proposalSelected.decodedContent && - typeof proposalSelected.decodedContent.title === 'string' - ? proposalSelected.decodedContent.title - : t('title.noTitle')} - </p> - - <IconButtonLink - Icon={ArrowOutwardRounded} - href={CHAIN_GOV_PROPOSAL_URL_TEMPLATE.replace('ID', proposalId)} - variant="ghost" - /> - </div> - - {proposalSelected.decodedContent && ( - <> - <ProposalStatusAndInfo - className="max-w-max" - info={[ - { - Icon: RotateRightOutlined, - label: t('title.status'), - Value: (props) => ( - <p {...props}> - {t( - PROPOSAL_STATUS_I18N_KEY_MAP[proposalSelected.status] - )} - </p> - ), - }, - { - Icon: TimelapseRounded, - label: t('title.votingOpened'), - Value: (props) => ( - <p {...props}> - {formatDateTimeTz(proposalSelected.votingStartTime)} - </p> - ), - }, - // If open for voting, show relative time until end. - ...(proposalSelected.status === - ProposalStatus.PROPOSAL_STATUS_VOTING_PERIOD - ? ([ - { - Icon: HourglassTopRounded, - label: t('title.timeLeft'), - Value: (props) => ( - <Tooltip - title={formatDateTimeTz( - proposalSelected.votingEndTime - )} - > - <p {...props}> - <TimeAgo - date={proposalSelected.votingEndTime} - formatter={timeAgoFormatter} - /> - </p> - </Tooltip> - ), - }, - ] as ProposalStatusAndInfoProps['info']) - : ([ - { - Icon: TimerRounded, - label: t('title.votingClosed'), - Value: (props) => ( - <p {...props}> - {formatDateTimeTz(proposalSelected.votingEndTime)} - </p> - ), - }, - ] as ProposalStatusAndInfoProps['info'])), - ]} - inline - /> - - {'description' in proposalSelected.decodedContent && - typeof proposalSelected.decodedContent.description === - 'string' && ( - <MarkdownRenderer - className="styled-scrollbar -mr-4 max-h-[40vh] overflow-y-auto pr-4" - markdown={proposalSelected.decodedContent.description.replace( - /\\n/g, - '\n' - )} - /> - )} - </> - )} - </div> + <GovernanceProposal + PayEntityDisplay={PayEntityDisplay} + TokenAmountDisplay={TokenAmountDisplay} + content={proposalSelected.decodedContent} + deposit={proposalSelected.totalDeposit} + endDate={proposalSelected.votingEndTime} + id={proposalSelected.proposalId.toString()} + startDate={proposalSelected.votingStartTime} + status={proposalSelected.status} + /> ) : ( // If not creating and no proposal selected, something went wrong. !isCreating && ( @@ -362,15 +269,3 @@ const VoteFooter = ({ </> ) } - -const PROPOSAL_STATUS_I18N_KEY_MAP: Record<ProposalStatus, string> = { - [ProposalStatus.PROPOSAL_STATUS_UNSPECIFIED]: 'govProposalStatus.unspecified', - [ProposalStatus.PROPOSAL_STATUS_DEPOSIT_PERIOD]: - 'govProposalStatus.depositPeriod', - [ProposalStatus.PROPOSAL_STATUS_VOTING_PERIOD]: - 'govProposalStatus.votingPeriod', - [ProposalStatus.PROPOSAL_STATUS_PASSED]: 'govProposalStatus.passed', - [ProposalStatus.PROPOSAL_STATUS_REJECTED]: 'govProposalStatus.rejected', - [ProposalStatus.PROPOSAL_STATUS_FAILED]: 'govProposalStatus.failed', - [ProposalStatus.UNRECOGNIZED]: 'govProposalStatus.unrecognized', -} diff --git a/packages/stateful/actions/core/chain_governance/GovernanceVote/index.tsx b/packages/stateful/actions/core/chain_governance/GovernanceVote/index.tsx index c496955353..9845ec02fd 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceVote/index.tsx +++ b/packages/stateful/actions/core/chain_governance/GovernanceVote/index.tsx @@ -26,6 +26,8 @@ import { objectMatchesStructure, } from '@dao-dao/utils' +import { PayEntityDisplay } from '../../../../components/PayEntityDisplay' +import { TokenAmountDisplay } from '../../../../components/TokenAmountDisplay' import { useActionOptions } from '../../../react' import { GovernanceVoteData, @@ -109,6 +111,8 @@ const Component: ActionComponent<undefined, GovernanceVoteData> = (props) => { ...(selectedProposal ? [selectedProposal] : []), ], existingVotesLoading, + PayEntityDisplay, + TokenAmountDisplay, }} /> ) diff --git a/packages/stateful/actions/core/chain_governance/index.ts b/packages/stateful/actions/core/chain_governance/index.ts index e9a307c497..02285cb5bd 100644 --- a/packages/stateful/actions/core/chain_governance/index.ts +++ b/packages/stateful/actions/core/chain_governance/index.ts @@ -1,5 +1,6 @@ import { ActionCategoryKey, ActionCategoryMaker } from '@dao-dao/types' +import { makeGovernanceProposalAction } from './GovernanceProposal' import { makeGovernanceVoteAction } from './GovernanceVote' import { makeValidatorActionsAction } from './ValidatorActions' @@ -9,5 +10,9 @@ export const makeChainGovernanceActionCategory: ActionCategoryMaker = ({ key: ActionCategoryKey.ChainGovernance, label: t('actionCategory.chainGovernanceLabel'), description: t('actionCategory.chainGovernanceDescription'), - actionMakers: [makeGovernanceVoteAction, makeValidatorActionsAction], + actionMakers: [ + makeGovernanceVoteAction, + makeGovernanceProposalAction, + makeValidatorActionsAction, + ], }) diff --git a/packages/stateful/components/PayEntityDisplay.tsx b/packages/stateful/components/PayEntityDisplay.tsx new file mode 100644 index 0000000000..e70c904a06 --- /dev/null +++ b/packages/stateful/components/PayEntityDisplay.tsx @@ -0,0 +1,41 @@ +import { waitForAll } from 'recoil' + +import { genericTokenBalanceSelector } from '@dao-dao/state/recoil' +import { + Loader, + PayEntityDisplay as StatelessPayEntityDisplay, + useCachedLoading, +} from '@dao-dao/stateless' +import { StatefulPayEntityDisplayProps, TokenType } from '@dao-dao/types' + +import { EntityDisplay } from './EntityDisplay' + +export const PayEntityDisplay = ({ + coins, + ...props +}: StatefulPayEntityDisplayProps) => { + // TODO: get chainId from useChain + + const tokenBalances = useCachedLoading( + waitForAll( + coins.map(({ denom }) => + genericTokenBalanceSelector({ + type: TokenType.Native, + denomOrAddress: denom, + walletAddress: props.recipient, + }) + ) + ), + [] + ) + + return tokenBalances.loading ? ( + <Loader size={32} /> + ) : ( + <StatelessPayEntityDisplay + {...props} + EntityDisplay={EntityDisplay} + tokens={tokenBalances.data} + /> + ) +} diff --git a/packages/stateful/components/TokenAmountDisplay.tsx b/packages/stateful/components/TokenAmountDisplay.tsx new file mode 100644 index 0000000000..a4f38fc167 --- /dev/null +++ b/packages/stateful/components/TokenAmountDisplay.tsx @@ -0,0 +1,47 @@ +import { genericTokenSelector } from '@dao-dao/state/recoil' +import { + TokenAmountDisplay as StatelessTokenAmountDisplay, + useCachedLoading, +} from '@dao-dao/stateless' +import { StatefulTokenAmountDisplayProps, TokenType } from '@dao-dao/types' +import { convertMicroDenomToDenomWithDecimals } from '@dao-dao/utils' + +export const TokenAmountDisplay = ({ + coin: { amount, denom }, + ...props +}: StatefulTokenAmountDisplayProps) => { + const loadingGenericToken = useCachedLoading( + genericTokenSelector({ + type: TokenType.Native, + denomOrAddress: denom, + }), + undefined + ) + + return ( + <StatelessTokenAmountDisplay + amount={ + loadingGenericToken.loading || !loadingGenericToken.data + ? { loading: true } + : { + loading: false, + data: convertMicroDenomToDenomWithDecimals( + amount, + loadingGenericToken.data.decimals + ), + } + } + decimals={ + loadingGenericToken.loading || !loadingGenericToken.data + ? 0 + : loadingGenericToken.data.decimals + } + symbol={ + loadingGenericToken.loading || !loadingGenericToken.data + ? '...' + : loadingGenericToken.data.symbol + } + {...props} + /> + ) +} diff --git a/packages/stateful/components/index.ts b/packages/stateful/components/index.ts index f1a0808067..ed67b7279f 100644 --- a/packages/stateful/components/index.ts +++ b/packages/stateful/components/index.ts @@ -13,6 +13,7 @@ export * from './EntityDisplay' export * from './IconButtonLink' export * from './LinkWrapper' export * from './NftCard' +export * from './PayEntityDisplay' export * from './PfpkNftSelectionModal' export * from './ProposalLine' export * from './ProposalList' @@ -21,6 +22,7 @@ export * from './SidebarWallet' export * from './StargazeNftImportModal' export * from './SuspenseLoader' export * from './SyncFollowingModal' +export * from './TokenAmountDisplay' export * from './Trans' export * from './WalletFiatRampModal' export * from './WalletNftCard' diff --git a/packages/stateful/package.json b/packages/stateful/package.json index eea7f92eac..f49bf14b14 100644 --- a/packages/stateful/package.json +++ b/packages/stateful/package.json @@ -29,7 +29,7 @@ "file-saver": "^2.0.5", "fuse.js": "^6.6.2", "i18next": "^21.8.10", - "interchain-rpc": "1.5.0", + "interchain-rpc": "^1.5.0", "json5": "^2.2.0", "lodash.clonedeep": "^4.5.0", "lodash.merge": "^4.6.2", diff --git a/packages/stateless/components/PayEntityDisplay.tsx b/packages/stateless/components/PayEntityDisplay.tsx new file mode 100644 index 0000000000..a7775f54ca --- /dev/null +++ b/packages/stateless/components/PayEntityDisplay.tsx @@ -0,0 +1,61 @@ +import { + ArrowRightAltRounded, + SubdirectoryArrowRightRounded, +} from '@mui/icons-material' +import clsx from 'clsx' + +import { TokenAmountDisplay, useDetectWrap } from '@dao-dao/stateless' +import { PayEntityDisplayProps, PayEntityDisplayRowProps } from '@dao-dao/types' +import { convertMicroDenomToDenomWithDecimals } from '@dao-dao/utils' + +export const PayEntityDisplay = ({ + tokens, + className, + ...props +}: PayEntityDisplayProps) => ( + <div className={clsx(className, 'space-y-2')}> + {tokens.map((token) => ( + <PayEntityDisplayRow + key={token.token.denomOrAddress} + token={token} + {...props} + /> + ))} + </div> +) + +const PayEntityDisplayRow = ({ + token: { token, balance: amount }, + recipient, + EntityDisplay, +}: PayEntityDisplayRowProps) => { + const { containerRef, childRef, wrapped } = useDetectWrap() + const Icon = wrapped ? SubdirectoryArrowRightRounded : ArrowRightAltRounded + + return ( + <div + className="flex min-w-0 flex-row flex-wrap items-stretch justify-between gap-x-3 gap-y-2" + ref={containerRef} + > + <TokenAmountDisplay + key={token.denomOrAddress} + amount={convertMicroDenomToDenomWithDecimals(amount, token.decimals)} + decimals={token.decimals} + iconUrl={token.imageUrl} + showFullAmount + symbol={token.symbol} + /> + + <div + className="flex min-w-0 grow flex-row items-stretch gap-2 sm:gap-3" + ref={childRef} + > + <div className={clsx('flex flex-row items-center', wrapped && 'pl-1')}> + <Icon className="!h-6 !w-6 text-text-secondary" /> + </div> + + <EntityDisplay address={recipient} /> + </div> + </div> + ) +} diff --git a/packages/stateless/components/emoji.tsx b/packages/stateless/components/emoji.tsx index 349d97f7d0..68a4b9c12a 100644 --- a/packages/stateless/components/emoji.tsx +++ b/packages/stateless/components/emoji.tsx @@ -125,6 +125,10 @@ export const BallotDepositEmoji = () => ( <EmojiWrapper emoji="🗳️" labelI18nKey="emoji.ballotBox" /> ) +export const RaisedHandEmoji = () => ( + <EmojiWrapper emoji="✋" labelI18nKey="emoji.raisedHand" /> +) + export const HourglassEmoji = () => ( <EmojiWrapper emoji="⏳" labelI18nKey="emoji.hourglass" /> ) diff --git a/packages/stateless/components/index.tsx b/packages/stateless/components/index.tsx index 331fb74374..94023a51da 100644 --- a/packages/stateless/components/index.tsx +++ b/packages/stateless/components/index.tsx @@ -37,6 +37,7 @@ export * from './NftCard' export * from './NoContent' export * from './OptionCard' export * from './Pagination' +export * from './PayEntityDisplay' export * from './StatusDisplay' export * from './Table' export * from './TokenSwapStatus' diff --git a/packages/stateless/components/inputs/TokenInput.tsx b/packages/stateless/components/inputs/TokenInput.tsx index 65ea3141a5..6288d72532 100644 --- a/packages/stateless/components/inputs/TokenInput.tsx +++ b/packages/stateless/components/inputs/TokenInput.tsx @@ -209,7 +209,9 @@ export const TokenInput = < className: 'min-w-[10rem] grow basis-[10rem]', contentContainerClassName: 'justify-between text-icon-primary !gap-4', - disabled: disabled, + disabled: + // Disable if there is only one token to choose from. + disabled || (!tokens.loading && tokens.data.length === 1), loading: tokens.loading, size: 'lg', variant: 'ghost_outline', diff --git a/packages/stateless/components/proposal/GovernanceProposal.tsx b/packages/stateless/components/proposal/GovernanceProposal.tsx new file mode 100644 index 0000000000..165d957833 --- /dev/null +++ b/packages/stateless/components/proposal/GovernanceProposal.tsx @@ -0,0 +1,200 @@ +import { + ArrowOutwardRounded, + HourglassTopRounded, + RotateRightOutlined, + TimelapseRounded, + TimerRounded, +} from '@mui/icons-material' +import { ProposalStatus } from 'cosmjs-types/cosmos/gov/v1beta1/gov' +import { ComponentType } from 'react' +import { useTranslation } from 'react-i18next' +import TimeAgo from 'react-timeago' + +import { + Coin, + GovProposalWithDecodedContent, + GovernanceProposalType, + StatefulPayEntityDisplayProps, + StatefulTokenAmountDisplayProps, +} from '@dao-dao/types' +import { + CHAIN_GOV_PROPOSAL_URL_TEMPLATE, + formatDateTimeTz, +} from '@dao-dao/utils' + +import { useTranslatedTimeDeltaFormatter } from '../../hooks' +import { IconButtonLink } from '../icon_buttons' +import { MarkdownRenderer } from '../MarkdownRenderer' +import { Tooltip } from '../tooltip' +import { + ProposalStatusAndInfo, + ProposalStatusAndInfoProps, +} from './ProposalStatusAndInfo' + +export type GovernanceProposalProps = { + // Defined if created. Adds external link to proposal and displays ID. + id?: string + status: ProposalStatus | 'pending' + content: GovProposalWithDecodedContent['decodedContent'] + deposit: Coin[] + startDate?: Date + // End of deposit period or voting period, depending on status. + endDate?: Date + + TokenAmountDisplay: ComponentType<StatefulTokenAmountDisplayProps> + // Needed to display CommunityPoolSpendProposal types. + PayEntityDisplay?: ComponentType<StatefulPayEntityDisplayProps> +} + +export const GovernanceProposal = ({ + id, + status, + content, + deposit, + startDate, + endDate, + TokenAmountDisplay, + PayEntityDisplay, +}: GovernanceProposalProps) => { + const { t } = useTranslation() + const timeAgoFormatter = useTranslatedTimeDeltaFormatter({ words: false }) + + const title = + 'title' in content.value && typeof content.value.title === 'string' + ? content.value.title + : t('title.noTitle') + const description = + 'description' in content.value && + typeof content.value.description === 'string' + ? content.value.description + : undefined + + return ( + <div className="flex flex-col gap-6"> + <div className="flex flex-row items-center gap-4"> + <p className="header-text"> + {id ? `${id} ` : ''} + {title} + </p> + + {id && ( + <IconButtonLink + Icon={ArrowOutwardRounded} + href={CHAIN_GOV_PROPOSAL_URL_TEMPLATE.replace('ID', id)} + variant="ghost" + /> + )} + </div> + + <ProposalStatusAndInfo + className="max-w-max" + info={[ + { + Icon: RotateRightOutlined, + label: t('title.status'), + Value: (props) => ( + <p {...props}>{t(PROPOSAL_STATUS_I18N_KEY_MAP[status])}</p> + ), + }, + ...(startDate + ? ([ + { + Icon: TimelapseRounded, + label: t('title.votingOpened'), + Value: (props) => ( + <p {...props}> + {typeof startDate === 'string' + ? startDate + : formatDateTimeTz(startDate)} + </p> + ), + }, + ] as ProposalStatusAndInfoProps['info']) + : []), + // If open for voting, show relative time until end. + ...(endDate + ? status === ProposalStatus.PROPOSAL_STATUS_VOTING_PERIOD || + status === ProposalStatus.PROPOSAL_STATUS_DEPOSIT_PERIOD + ? ([ + { + Icon: HourglassTopRounded, + label: t('title.timeLeft'), + Value: (props) => ( + <Tooltip title={endDate}> + <p {...props}> + <TimeAgo + date={endDate} + formatter={timeAgoFormatter} + /> + </p> + </Tooltip> + ), + }, + ] as ProposalStatusAndInfoProps['info']) + : // If closed, show end date. + ([ + { + Icon: TimerRounded, + label: t('title.votingClosed'), + Value: (props) => ( + <p {...props}>{formatDateTimeTz(endDate)}</p> + ), + }, + ] as ProposalStatusAndInfoProps['info']) + : []), + ]} + inline + /> + + {!!description && ( + <MarkdownRenderer + className="styled-scrollbar -mr-4 max-h-[40vh] overflow-y-auto pr-4" + markdown={description.replace(/\\n/g, '\n')} + /> + )} + + {!!deposit.length && ( + <div className="space-y-3"> + <p className="text-text-tertiary">{t('title.deposit')}</p> + + <div className="space-y-2"> + {deposit.map((coin) => ( + <TokenAmountDisplay key={coin.denom} coin={coin} /> + ))} + </div> + </div> + )} + + {PayEntityDisplay && + !!content && + content.typeUrl === + GovernanceProposalType.CommunityPoolSpendProposal && ( + <div className="space-y-3"> + <p className="text-text-tertiary"> + {t('info.communityPoolSpendProposal')} + </p> + + <PayEntityDisplay + coins={content.value.amount} + recipient={content.value.recipient} + /> + </div> + )} + </div> + ) +} + +const PROPOSAL_STATUS_I18N_KEY_MAP: Record<ProposalStatus | 'pending', string> = + { + [ProposalStatus.PROPOSAL_STATUS_UNSPECIFIED]: + 'govProposalStatus.unspecified', + [ProposalStatus.PROPOSAL_STATUS_DEPOSIT_PERIOD]: + 'govProposalStatus.depositPeriod', + [ProposalStatus.PROPOSAL_STATUS_VOTING_PERIOD]: + 'govProposalStatus.votingPeriod', + [ProposalStatus.PROPOSAL_STATUS_PASSED]: 'govProposalStatus.passed', + [ProposalStatus.PROPOSAL_STATUS_REJECTED]: 'govProposalStatus.rejected', + [ProposalStatus.PROPOSAL_STATUS_FAILED]: 'govProposalStatus.failed', + [ProposalStatus.UNRECOGNIZED]: 'govProposalStatus.unrecognized', + pending: 'govProposalStatus.pendingSubmission', + } diff --git a/packages/stateless/components/proposal/index.tsx b/packages/stateless/components/proposal/index.tsx index c1cd3af762..e705fec3a1 100644 --- a/packages/stateless/components/proposal/index.tsx +++ b/packages/stateless/components/proposal/index.tsx @@ -1,3 +1,4 @@ +export * from './GovernanceProposal' export * from './ProgressBar' export * from './ProposalCard' export * from './ProposalContentDisplay' diff --git a/packages/stateless/components/token/TokenAmountDisplay.tsx b/packages/stateless/components/token/TokenAmountDisplay.tsx index 8db1e873fd..8cf5c19357 100644 --- a/packages/stateless/components/token/TokenAmountDisplay.tsx +++ b/packages/stateless/components/token/TokenAmountDisplay.tsx @@ -1,8 +1,7 @@ import clsx from 'clsx' -import { ComponentPropsWithoutRef } from 'react' import { useTranslation } from 'react-i18next' -import { LoadingData } from '@dao-dao/types' +import { TokenAmountDisplayProps } from '@dao-dao/types' import { USDC_DECIMALS, formatTime, @@ -34,51 +33,6 @@ const USD_ESTIMATE_DEFAULT_MAX_DECIMALS = 2 // Maximum decimals to use in a large compacted value. const LARGE_COMPACT_MAX_DECIMALS = 2 -export type TokenAmountDisplayProps = Omit< - ComponentPropsWithoutRef<'p'>, - 'children' -> & { - amount: number | LoadingData<number> - prefix?: string - prefixClassName?: string - suffix?: string - suffixClassName?: string - // Max decimals to display. - maxDecimals?: number - // Don't show approximation indication (like a tilde). - hideApprox?: boolean - // Add to tooltip if present. - dateFetched?: Date - // Show full amount if true. - showFullAmount?: boolean - // If present, will add a rounded icon to the left. - iconUrl?: string - iconClassName?: string -} & ( // If not USD estimate, require symbol and decimals. - | { - symbol: string - hideSymbol?: never - // Full decimal precision of the value. - decimals: number - estimatedUsdValue?: false - } - // Alow hiding symbol. - | { - symbol?: never - hideSymbol: true - // Full decimal precision of the value. - decimals: number - estimatedUsdValue?: false - } - // If USD estimate, disallow symbol and decimals since we'll use USDC's. - | { - symbol?: never - hideSymbol?: boolean - decimals?: never - estimatedUsdValue: true - } - ) - export const TokenAmountDisplay = ({ amount: _amount, decimals: _decimals, diff --git a/packages/types/actions.ts b/packages/types/actions.ts index 436c4e2a20..be3f3275b8 100644 --- a/packages/types/actions.ts +++ b/packages/types/actions.ts @@ -42,6 +42,7 @@ export enum ActionKey { WithdrawTokenSwap = 'withdrawTokenSwap', ManageStorageItems = 'manageStorageItems', GovernanceVote = 'governanceVote', + GovernanceProposal = 'governanceProposal', UpgradeV1ToV2 = 'upgradeV1ToV2', EnableVestingPayments = 'enableVestingPayments', EnableRetroactiveCompensation = 'enableRetroactiveCompensation', diff --git a/packages/types/package.json b/packages/types/package.json index 03465a1240..36fc4c5249 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -23,6 +23,6 @@ }, "prettier": "@dao-dao/config/prettier", "dependencies": { - "interchain-rpc": "1.5.0" + "interchain-rpc": "^1.5.0" } } diff --git a/packages/types/state.ts b/packages/types/state.ts index e1a17aae44..3c71dfe06a 100644 --- a/packages/types/state.ts +++ b/packages/types/state.ts @@ -1,6 +1,8 @@ import { cosmos } from 'interchain-rpc' +import { TextProposal } from 'interchain-rpc/types/codegen/cosmos/gov/v1beta1/gov' import { TokenInfoResponse } from './contracts/Cw20Base' +import { DecodedStargateMsg } from './utils' export type CachedLoadable<T> = | { @@ -38,6 +40,20 @@ export interface TokenInfoResponseWithAddressAndLogo extends TokenInfoResponse { export type GovProposal = ReturnType< typeof cosmos.gov.v1beta1.Proposal['fromPartial'] > +export type GovProposalDecodedContent = DecodedStargateMsg< + TextProposal & { + // May contain additional fields if not a TextProposal. + [key: string]: any + } +>['stargate'] export type GovProposalWithDecodedContent = GovProposal & { - decodedContent: any + decodedContent: GovProposalDecodedContent +} + +export enum GovernanceProposalType { + TextProposal = '/cosmos.gov.v1beta1.TextProposal', + CommunityPoolSpendProposal = '/cosmos.distribution.v1beta1.CommunityPoolSpendProposal', + ParameterChangeProposal = '/cosmos.gov.v1beta1.ParameterChangeProposal', + SoftwareUpgradeProposal = '/cosmos.upgrade.v1beta1.SoftwareUpgradeProposal', + CancelSoftwareUpgradeProposal = '/cosmos.upgrade.v1beta1.CancelSoftwareUpgradeProposal', } diff --git a/packages/types/stateless/KadoModal.tsx b/packages/types/stateless/KadoModal.ts similarity index 100% rename from packages/types/stateless/KadoModal.tsx rename to packages/types/stateless/KadoModal.ts diff --git a/packages/types/stateless/Modal.tsx b/packages/types/stateless/Modal.ts similarity index 100% rename from packages/types/stateless/Modal.tsx rename to packages/types/stateless/Modal.ts diff --git a/packages/types/stateless/PayEntityDisplay.ts b/packages/types/stateless/PayEntityDisplay.ts new file mode 100644 index 0000000000..94719dc1a3 --- /dev/null +++ b/packages/types/stateless/PayEntityDisplay.ts @@ -0,0 +1,23 @@ +import { ComponentType } from 'react' + +import { Coin } from '../contracts/common' +import { GenericTokenBalance } from '../token' +import { StatefulEntityDisplayProps } from './EntityDisplay' + +export type PayEntityDisplayRowProps = { + token: GenericTokenBalance + recipient: string + EntityDisplay: ComponentType<StatefulEntityDisplayProps> +} + +export type PayEntityDisplayProps = Omit<PayEntityDisplayRowProps, 'token'> & { + tokens: PayEntityDisplayRowProps['token'][] + className?: string +} + +export type StatefulPayEntityDisplayProps = Omit< + PayEntityDisplayProps, + 'tokens' | 'EntityDisplay' +> & { + coins: Coin[] +} diff --git a/packages/types/stateless/Row.tsx b/packages/types/stateless/Row.ts similarity index 100% rename from packages/types/stateless/Row.tsx rename to packages/types/stateless/Row.ts diff --git a/packages/types/stateless/TokenAmountDisplay.ts b/packages/types/stateless/TokenAmountDisplay.ts new file mode 100644 index 0000000000..1df850dec0 --- /dev/null +++ b/packages/types/stateless/TokenAmountDisplay.ts @@ -0,0 +1,63 @@ +import { ComponentPropsWithoutRef } from 'react' + +import { Coin } from '../contracts' +import { LoadingData } from './common' + +export type TokenAmountDisplayProps = Omit< + ComponentPropsWithoutRef<'p'>, + 'children' +> & { + amount: number | LoadingData<number> + prefix?: string + prefixClassName?: string + suffix?: string + suffixClassName?: string + // Max decimals to display. + maxDecimals?: number + // Don't show approximation indication (like a tilde). + hideApprox?: boolean + // Add to tooltip if present. + dateFetched?: Date + // Show full amount if true. + showFullAmount?: boolean + // If present, will add a rounded icon to the left. + iconUrl?: string + iconClassName?: string +} & ( // If not USD estimate, require symbol and decimals. + | { + symbol: string + hideSymbol?: never + // Full decimal precision of the value. + decimals: number + estimatedUsdValue?: false + } + // Alow hiding symbol. + | { + symbol?: never + hideSymbol: true + // Full decimal precision of the value. + decimals: number + estimatedUsdValue?: false + } + // If USD estimate, disallow symbol and decimals since we'll use USDC's. + | { + symbol?: never + hideSymbol?: boolean + decimals?: never + estimatedUsdValue: true + } + ) + +export type StatefulTokenAmountDisplayProps = Pick< + TokenAmountDisplayProps, + | 'prefix' + | 'prefixClassName' + | 'suffix' + | 'suffixClassName' + | 'maxDecimals' + | 'hideApprox' + | 'showFullAmount' + | 'iconClassName' +> & { + coin: Coin +} diff --git a/packages/types/stateless/index.ts b/packages/types/stateless/index.ts index aa7914e0fd..f2e14d3ffd 100644 --- a/packages/types/stateless/index.ts +++ b/packages/types/stateless/index.ts @@ -25,6 +25,7 @@ export * from './Loader' export * from './Logo' export * from './Modal' export * from './PageHeader' +export * from './PayEntityDisplay' export * from './Popup' export * from './ProfileCardWrapper' export * from './ProfileNewProposalCard' @@ -38,6 +39,7 @@ export * from './SegmentedControls' export * from './StakingModal' export * from './SuspenseLoader' export * from './theme' +export * from './TokenAmountDisplay' export * from './TokenSwapStatus' export * from './Trans' export * from './ValidatorPicker' diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json index 977eed25ec..6be09ce8b0 100644 --- a/packages/types/tsconfig.json +++ b/packages/types/tsconfig.json @@ -1,5 +1,5 @@ { "extends": "@dao-dao/config/ts/react-library.json", - "include": ["**/*.ts", "**/*.tsx"], + "include": ["**/*.ts", "**/*.tsx", "stateless/Row.ts"], "exclude": ["node_modules"] } diff --git a/packages/utils/messages/protobuf.ts b/packages/utils/messages/protobuf.ts index 64972fba1a..1c7a676a5b 100644 --- a/packages/utils/messages/protobuf.ts +++ b/packages/utils/messages/protobuf.ts @@ -24,7 +24,12 @@ import { import { SendAuthorization } from 'cosmjs-types/cosmos/bank/v1beta1/authz' import { PubKey } from 'cosmjs-types/cosmos/crypto/ed25519/keys' import { VoteOption as CosmosGovVoteOption } from 'cosmjs-types/cosmos/gov/v1beta1/gov' +import { ParameterChangeProposal } from 'cosmjs-types/cosmos/params/v1beta1/params' import { MsgUnjail } from 'cosmjs-types/cosmos/slashing/v1beta1/tx' +import { + CancelSoftwareUpgradeProposal, + SoftwareUpgradeProposal, +} from 'cosmjs-types/cosmos/upgrade/v1beta1/upgrade' import { AcceptedMessageKeysFilter, AcceptedMessagesFilter, @@ -47,6 +52,7 @@ import { CosmosMsgFor_Empty, DecodedStargateMsg, GovProposal, + GovProposalDecodedContent, GovProposalWithDecodedContent, StargateMsg, VoteOption, @@ -488,6 +494,22 @@ export const typesRegistry = new Registry([ '/osmosis.tokenfactory.v1beta1.MsgMint', osmosis.tokenfactory.v1beta1.MsgMint, ], + + // Governance proposal types + ['/cosmos.gov.v1beta1.TextProposal', cosmos.gov.v1beta1.TextProposal], + [ + '/cosmos.distribution.v1beta1.CommunityPoolSpendProposal', + cosmos.distribution.v1beta1.CommunityPoolSpendProposal, + ], + ['/cosmos.gov.v1beta1.ParameterChangeProposal', ParameterChangeProposal], + [ + '/cosmos.upgrade.v1beta1.SoftwareUpgradeProposal', + SoftwareUpgradeProposal, + ], + [ + '/cosmos.upgrade.v1beta1.CancelSoftwareUpgradeProposal', + CancelSoftwareUpgradeProposal, + ], ] as ReadonlyArray<[string, GeneratedType]>), ]) @@ -564,15 +586,27 @@ export const decodeStargateMessage = ({ // Decode governance proposal content using a protobuf. export const decodeGovProposalContent = ( + content: Any +): GovProposalDecodedContent => { + try { + return decodeRawProtobufMsg(content) + } catch (err) { + // It seems as though all proposals can be decoded as a TextProposal, as + // they tend to start with `title` and `description` fields. If decoding as + // a specific type fails, try decoding as a TextProposal. + return { + typeUrl: '/cosmos.gov.v1beta1.TextProposal', + value: cosmos.gov.v1beta1.TextProposal.decode(content.value), + } + } +} + +// Decode governance proposal content using a protobuf. +export const decodeGovProposal = ( govProposal: GovProposal ): GovProposalWithDecodedContent => ({ ...govProposal, - // It seems as though all proposals can be decoded as a TextProposal, as they - // tend to start with `title` and `description` fields. This successfully - // decoded the first 80 proposals, so it's probably intentional. - decodedContent: cosmos.gov.v1beta1.TextProposal.decode( - govProposal.content.value - ), + decodedContent: decodeGovProposalContent(govProposal.content), }) export const isDecodedStargateMsg = (msg: any): msg is DecodedStargateMsg => diff --git a/packages/utils/package.json b/packages/utils/package.json index ea47feb479..a662c121cb 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -33,7 +33,7 @@ "@keplr-wallet/types": "^0.11.49", "@noahsaso/cosmodal": "0.11.1", "@types/ripemd160": "^2.0.0", - "interchain-rpc": "1.5.0", + "interchain-rpc": "^1.5.0", "jest": "^29.1.1", "next": "^13.3.0", "nft.storage": "^7.0.0", diff --git a/yarn.lock b/yarn.lock index d88c07e45d..8488b3cc60 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13396,7 +13396,7 @@ intercept-stdout@^0.1.2: dependencies: lodash.toarray "^3.0.0" -interchain-rpc@1.5.0: +interchain-rpc@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/interchain-rpc/-/interchain-rpc-1.5.0.tgz#e9b28bf26d4d4be3db35b4fd4b065e6ce281bba4" integrity sha512-PPraOwL97IvnccplPIHjrcUfysywFdPK38yQY7rYQFfb85i8gSeenX6K5fkKdboh/ZpRRo+cpausqDyhIKlsGA== From 4826d69ab678ac6227da736a35191504d3306d67 Mon Sep 17 00:00:00 2001 From: Noah Saso <noahsaso@gmail.com> Date: Tue, 11 Jul 2023 00:53:59 +0200 Subject: [PATCH 02/20] Fixed gov prop action. --- .../GovernanceProposal/index.tsx | 99 +++++++++++++------ 1 file changed, 67 insertions(+), 32 deletions(-) diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx b/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx index 99a062b2e3..cfcf8e37a1 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx @@ -18,6 +18,8 @@ import { UseTransformToCosmos, } from '@dao-dao/types/actions' import { + convertDenomToMicroDenomWithDecimals, + convertMicroDenomToDenomWithDecimals, decodeGovProposalContent, encodeRawProtobufMsg, isDecodedStargateMsg, @@ -114,16 +116,53 @@ export const makeGovernanceProposalAction: ActionMaker< }), undefined ) + const minDeposits = useCachedLoading( + govParams.loading || !govParams.data + ? undefined + : waitForAll( + govParams.data.depositParams.minDeposit.map(({ denom }) => + genericTokenSelector({ + type: TokenType.Native, + denomOrAddress: denom, + }) + ) + ), + [] + ) + + const deposit = + minDeposits.loading || govParams.loading || !govParams.data + ? undefined + : govParams.data.depositParams.minDeposit[0] return { type: GovernanceProposalType.TextProposal, title: '', description: '', deposit: - govParams.loading || !govParams.data - ? [] - : [{ ...govParams.data.depositParams.minDeposit[0] }], - amount: [], + deposit && !minDeposits.loading + ? [ + { + denom: deposit.denom, + amount: convertMicroDenomToDenomWithDecimals( + deposit.amount, + minDeposits.data[0].decimals + ).toString(), + }, + ] + : [], + amount: + deposit && !minDeposits.loading + ? [ + { + denom: deposit.denom, + amount: convertDenomToMicroDenomWithDecimals( + 1000, + minDeposits.data[0].decimals + ).toString(), + }, + ] + : [], parameterChanges: defaultParameterChanges, upgradePlan: defaultPlan, } @@ -142,51 +181,47 @@ export const makeGovernanceProposalAction: ActionMaker< parameterChanges, upgradePlan, }) => { - const content = encodeRawProtobufMsg( - type === GovernanceProposalType.CommunityPoolSpendProposal - ? { - typeUrl: type, - value: { + const content = encodeRawProtobufMsg({ + typeUrl: type, + value: + type === GovernanceProposalType.CommunityPoolSpendProposal + ? ({ title, description, - amount, + amount: amount.map(({ amount, denom }) => ({ + denom, + amount: amount.toString(), + })), recipient: address, - } as CommunityPoolSpendProposal, - } - : type === GovernanceProposalType.ParameterChangeProposal - ? { - typeUrl: type, - value: { + } as CommunityPoolSpendProposal) + : type === GovernanceProposalType.ParameterChangeProposal + ? ({ title, description, changes: JSON.parse(parameterChanges), - } as ParameterChangeProposal, - } - : type === GovernanceProposalType.SoftwareUpgradeProposal - ? { - typeUrl: type, - value: { + } as ParameterChangeProposal) + : type === GovernanceProposalType.SoftwareUpgradeProposal + ? ({ title, description, plan: JSON.parse(upgradePlan), - } as SoftwareUpgradeProposal, - } - : // Default to text proposal. - { - typeUrl: type, - value: { + } as SoftwareUpgradeProposal) + : // Default to text proposal. + ({ title, description, - } as TextProposal, - } - ) + } as TextProposal), + }) return makeStargateMessage({ stargate: { typeUrl: SUBMIT_PROPOSAL_TYPE_URL, value: { content, - initialDeposit: deposit, + initialDeposit: deposit.map(({ amount, denom }) => ({ + amount: amount.toString(), + denom, + })), proposer: address, } as MsgSubmitProposal, }, From f7e9c7b86ac0781a678a8ce47ecd7d89efd1039f Mon Sep 17 00:00:00 2001 From: Noah Saso <noahsaso@gmail.com> Date: Tue, 11 Jul 2023 01:19:13 +0200 Subject: [PATCH 03/20] Added recipient for community spend prop. --- packages/i18n/locales/en/translation.json | 1 + .../GovernanceProposal/Component.stories.tsx | 10 +- .../GovernanceProposal/Component.tsx | 167 ++++++++++-------- .../GovernanceProposal/index.tsx | 25 +-- 4 files changed, 116 insertions(+), 87 deletions(-) diff --git a/packages/i18n/locales/en/translation.json b/packages/i18n/locales/en/translation.json index 4e5cf286a4..c0203b676e 100644 --- a/packages/i18n/locales/en/translation.json +++ b/packages/i18n/locales/en/translation.json @@ -492,6 +492,7 @@ "proposalsDescriptionPlaceholder": "Give your proposal a description...", "proposalsName": "Proposal's name", "proposalsNamePlaceholder": "Give your proposal a name", + "proposedSpends": "Proposed spends", "quorumDescription": "The minimum percentage of voting power that must vote on a proposal for it to be considered. For example, in the US House of Representatives, 218 members must be present for a vote. If you have a DAO with many inactive members, setting this value too high may make it difficult to pass proposals.", "quorumTitle": "Quorum", "ratingInstructionsPlaceholder": "This is what DAO members will see when they are rating the contributions. Explain how they should rate.", diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.stories.tsx b/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.stories.tsx index c389e94168..313311907f 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.stories.tsx +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.stories.tsx @@ -3,6 +3,7 @@ import { ComponentMeta, ComponentStory } from '@storybook/react' import { ReactHookFormDecorator } from '@dao-dao/storybook' import { GovernanceProposalType } from '@dao-dao/types' +import { AddressInput } from '../../../../components/AddressInput' import { PayEntityDisplay } from '../../../../components/PayEntityDisplay' import { TokenAmountDisplay } from '../../../../components/TokenAmountDisplay' import { GovernanceProposalComponent } from './Component' @@ -34,7 +35,13 @@ Default.args = { denom: 'ujunox', }, ], - amount: [], + spends: [ + { + amount: '100000000', + denom: 'ujunox', + }, + ], + spendRecipient: 'junoRecipient', parameterChanges: JSON.stringify([]), upgradePlan: JSON.stringify( { @@ -54,5 +61,6 @@ Default.args = { minDeposits: { loading: false, data: [] }, PayEntityDisplay, TokenAmountDisplay, + AddressInput, }, } diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx b/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx index f7fb36e0ed..09cb92354d 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx @@ -17,6 +17,7 @@ import { TokenInput, } from '@dao-dao/stateless' import { + AddressInputProps, GenericToken, GenericTokenBalance, GovernanceProposalType, @@ -27,9 +28,9 @@ import { import { ActionComponent } from '@dao-dao/types/actions' import { NATIVE_TOKEN, - convertDenomToMicroDenomWithDecimals, convertMicroDenomToDenomWithDecimals, ibcAssets, + validateAddress, validateJSON, validateRequired, } from '@dao-dao/utils' @@ -40,6 +41,7 @@ export type GovernanceProposalOptions = { minDeposits: LoadingData<GenericTokenBalance[]> PayEntityDisplay: ComponentType<StatefulPayEntityDisplayProps> TokenAmountDisplay: ComponentType<StatefulTokenAmountDisplayProps> + AddressInput: ComponentType<AddressInputProps<GovernanceProposalData>> } export type GovernanceProposalData = { @@ -48,7 +50,8 @@ export type GovernanceProposalData = { description: string deposit: Coin[] // GovernanceProposalType.CommunityPoolSpendProposal - amount: Coin[] + spends: Coin[] + spendRecipient: string // GovernanceProposalType.ParameterChangeProposal parameterChanges: string // GovernanceProposalType.SoftwareUpgradeProposal @@ -62,7 +65,7 @@ export const GovernanceProposalComponent: ActionComponent< fieldNamePrefix, errors, isCreating, - options: { minDeposits, PayEntityDisplay, TokenAmountDisplay }, + options: { minDeposits, PayEntityDisplay, TokenAmountDisplay, AddressInput }, data, }) => { const { address } = useActionOptions() @@ -77,12 +80,12 @@ export const GovernanceProposalComponent: ActionComponent< ) const { - fields: amountFields, - append: appendCoin, - remove: removeCoin, + fields: spendFields, + append: appendSpend, + remove: removeSpend, } = useFieldArray({ control, - name: (fieldNamePrefix + 'amount') as 'amount', + name: (fieldNamePrefix + 'spends') as 'spends', }) const availableTokens: GenericToken[] = [ @@ -149,7 +152,7 @@ export const GovernanceProposalComponent: ActionComponent< <div className="space-y-1"> <InputLabel name={t('form.initialDeposit')} /> <TokenInput - amountError={errors?.amount} + amountError={errors?.deposit?.[0]?.amount} amountFieldName={ (fieldNamePrefix + 'deposit.0.amount') as 'deposit.0.amount' } @@ -185,82 +188,94 @@ export const GovernanceProposalComponent: ActionComponent< </div> {data.type === GovernanceProposalType.CommunityPoolSpendProposal && ( - <div className="flex flex-col gap-1"> - <InputLabel name={t('form.funds')} /> + <> + <div className="flex flex-col gap-1"> + <InputLabel name={t('form.recipient')} /> + <AddressInput + disabled={!isCreating} + error={errors?.spendRecipient} + fieldName={ + (fieldNamePrefix + 'spendRecipient') as 'spendRecipient' + } + register={register} + validation={[validateRequired, validateAddress]} + /> + <InputErrorMessage error={errors?.spendRecipient} /> + </div> - <div className="flex flex-row flex-wrap items-end justify-between gap-6"> - <div className="flex grow flex-col gap-1"> - <div className="flex flex-col items-stretch gap-2"> - {amountFields.map(({ id, denom }, index) => { - const selectedToken = availableTokens.find( - ({ denomOrAddress }) => denomOrAddress === denom - ) - if (!selectedToken) { - return null - } + <div className="flex flex-col gap-1"> + <InputLabel name={t('form.proposedSpends')} /> - return ( - <div - key={id} - className="flex flex-row items-center justify-between gap-2" - > - <TokenInput - amountError={errors?.depositInfo?.amount} - amountFieldName={ - (fieldNamePrefix + - `amount.${index}.amount`) as `amount.${number}.amount` - } - amountStep={convertMicroDenomToDenomWithDecimals( - 1, - selectedToken.decimals - )} - containerClassName="grow" - convertMicroDenom - onSelectToken={({ denomOrAddress }) => - setValue( + <div className="flex flex-row flex-wrap items-end justify-between gap-6"> + <div className="flex grow flex-col gap-1"> + <div className="flex flex-col items-stretch gap-2"> + {spendFields.map(({ id, denom }, index) => { + const selectedToken = availableTokens.find( + ({ denomOrAddress }) => denomOrAddress === denom + ) + if (!selectedToken) { + return null + } + + return ( + <div + key={id} + className="flex flex-row items-center gap-2" + > + <TokenInput + amountError={errors?.spends?.[index]?.amount} + amountFieldName={ (fieldNamePrefix + - `amount.${index}.denom`) as `amount.${number}.denom`, - denomOrAddress - ) - } - register={register} - selectedToken={selectedToken} - setValue={setValue} - tokens={{ loading: false, data: availableTokens }} - watch={watch} - /> + `spends.${index}.amount`) as `spends.${number}.amount` + } + amountStep={convertMicroDenomToDenomWithDecimals( + 1, + selectedToken.decimals + )} + convertMicroDenom + onSelectToken={({ denomOrAddress }) => + setValue( + (fieldNamePrefix + + `spends.${index}.denom`) as `spends.${number}.denom`, + denomOrAddress + ) + } + register={register} + selectedToken={selectedToken} + setValue={setValue} + tokens={{ loading: false, data: availableTokens }} + watch={watch} + /> - <IconButton - Icon={Close} - onClick={() => removeCoin(index)} - size="sm" - variant="ghost" - /> - </div> - ) - })} + <IconButton + Icon={Close} + onClick={() => removeSpend(index)} + size="sm" + variant="ghost" + /> + </div> + ) + })} - {isCreating && ( - <Button - className="self-start" - onClick={() => - appendCoin({ - amount: convertDenomToMicroDenomWithDecimals( - 1, - NATIVE_TOKEN.decimals - ).toString(), - denom: NATIVE_TOKEN.denomOrAddress, - }) - } - variant="secondary" - > - {t('button.addPayment')} - </Button> - )} + {isCreating && ( + <Button + className="self-start" + onClick={() => + appendSpend({ + amount: '1', + denom: NATIVE_TOKEN.denomOrAddress, + }) + } + variant="secondary" + > + {t('button.addPayment')} + </Button> + )} + </div> </div> </div> </div> - </div> + </> )} {data.type === GovernanceProposalType.ParameterChangeProposal && ( diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx b/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx index cfcf8e37a1..ebdbef7776 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx @@ -18,7 +18,6 @@ import { UseTransformToCosmos, } from '@dao-dao/types/actions' import { - convertDenomToMicroDenomWithDecimals, convertMicroDenomToDenomWithDecimals, decodeGovProposalContent, encodeRawProtobufMsg, @@ -27,6 +26,7 @@ import { objectMatchesStructure, } from '@dao-dao/utils' +import { AddressInput } from '../../../../components/AddressInput' import { PayEntityDisplay } from '../../../../components/PayEntityDisplay' import { TokenAmountDisplay } from '../../../../components/TokenAmountDisplay' import { useActionOptions } from '../../../react' @@ -79,6 +79,7 @@ const Component: ActionComponent<undefined, GovernanceProposalData> = ( }, PayEntityDisplay, TokenAmountDisplay, + AddressInput, }} /> ) @@ -151,18 +152,16 @@ export const makeGovernanceProposalAction: ActionMaker< }, ] : [], - amount: + spends: deposit && !minDeposits.loading ? [ { denom: deposit.denom, - amount: convertDenomToMicroDenomWithDecimals( - 1000, - minDeposits.data[0].decimals - ).toString(), + amount: '1000', }, ] : [], + spendRecipient: address, parameterChanges: defaultParameterChanges, upgradePlan: defaultPlan, } @@ -177,7 +176,8 @@ export const makeGovernanceProposalAction: ActionMaker< title, description, deposit, - amount, + spends, + spendRecipient, parameterChanges, upgradePlan, }) => { @@ -188,11 +188,11 @@ export const makeGovernanceProposalAction: ActionMaker< ? ({ title, description, - amount: amount.map(({ amount, denom }) => ({ + amount: spends.map(({ amount, denom }) => ({ denom, amount: amount.toString(), })), - recipient: address, + recipient: spendRecipient, } as CommunityPoolSpendProposal) : type === GovernanceProposalType.ParameterChangeProposal ? ({ @@ -263,11 +263,16 @@ export const makeGovernanceProposalAction: ActionMaker< title: decodedContent.value.title, description: decodedContent.value.description, deposit: msg.stargate.value.initialDeposit, - amount: + spends: decodedContent.typeUrl === GovernanceProposalType.CommunityPoolSpendProposal ? decodedContent.value.amount : [], + spendRecipient: + decodedContent.typeUrl === + GovernanceProposalType.CommunityPoolSpendProposal + ? decodedContent.value.recipient + : address, parameterChanges: decodedContent.typeUrl === GovernanceProposalType.ParameterChangeProposal From a9c6eaed67fd7ee533aa40b1dc975950a7a5ebd4 Mon Sep 17 00:00:00 2001 From: Noah Saso <noahsaso@gmail.com> Date: Tue, 11 Jul 2023 01:21:28 +0200 Subject: [PATCH 04/20] Fix token input select arrow showing when disabled. --- packages/stateless/components/inputs/TokenInput.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/stateless/components/inputs/TokenInput.tsx b/packages/stateless/components/inputs/TokenInput.tsx index 6288d72532..1a83e4c397 100644 --- a/packages/stateless/components/inputs/TokenInput.tsx +++ b/packages/stateless/components/inputs/TokenInput.tsx @@ -153,6 +153,9 @@ export const TokenInput = < [amount, readOnly, selectedToken, t, tokenFallback] ) + const selectDisabled = // Disable if there is only one token to choose from. + disabled || (!tokens.loading && tokens.data.length === 1) + return ( <div className={clsx( @@ -209,9 +212,7 @@ export const TokenInput = < className: 'min-w-[10rem] grow basis-[10rem]', contentContainerClassName: 'justify-between text-icon-primary !gap-4', - disabled: - // Disable if there is only one token to choose from. - disabled || (!tokens.loading && tokens.data.length === 1), + disabled: selectDisabled, loading: tokens.loading, size: 'lg', variant: 'ghost_outline', @@ -219,7 +220,7 @@ export const TokenInput = < <> {selectedTokenDisplay} - {!disabled && <ArrowDropDown className="!h-6 !w-6" />} + {!selectDisabled && <ArrowDropDown className="!h-6 !w-6" />} </> ), }, From c6720bcb6bea1b24eb71c8ba88bdc37eadd73e77 Mon Sep 17 00:00:00 2001 From: Noah Saso <noahsaso@gmail.com> Date: Tue, 11 Jul 2023 01:23:15 +0200 Subject: [PATCH 05/20] Updated README. --- .../GovernanceProposal/README.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/README.md b/packages/stateful/actions/core/chain_governance/GovernanceProposal/README.md index 8a25227521..4dd7e73fad 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceProposal/README.md +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/README.md @@ -18,13 +18,22 @@ guide](https://github.com/DA0-DA0/dao-dao-ui/wiki/Bulk-importing-actions). "type": "<TYPE>", "title": "<TITLE>", "description": "<DESCRIPTION>", - "initialDeposit": [ + "deposit": [ + { + "denom": "<DENOM>", + "amount": "<AMOUNT>" + } + ], + "spends": [ { "denom": "<DENOM>", "amount": "<AMOUNT>" }, ... - ] + ], + "spendRecipient": "<ADDRESS>", + "parameterChanges": "<PARAMETER CHANGES JSON>", + "upgradePlan": "<UPGRADE PLAN JSON>" } ``` @@ -35,3 +44,7 @@ guide](https://github.com/DA0-DA0/dao-dao-ui/wiki/Bulk-importing-actions). - `/cosmos.params.v1beta1.ParameterChangeProposal` - `/cosmos.upgrade.v1beta1.SoftwareUpgradeProposal` - `/cosmos.upgrade.v1beta1.CancelSoftwareUpgradeProposal` + +`spends` and `spendRecipient` are used with `CommunityPoolSpendProposal`, +`parameterChanges` is used with `ParameterChangeProposal`, and `upgradePlan` is +used with `SoftwareUpgradeProposal`. From b74722d2db6bc00b25cea17a70aa108f403a1cef Mon Sep 17 00:00:00 2001 From: Noah Saso <noahsaso@gmail.com> Date: Tue, 11 Jul 2023 01:24:36 +0200 Subject: [PATCH 06/20] Fixed build error in vote. --- .../core/chain_governance/GovernanceVote/Component.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/stateful/actions/core/chain_governance/GovernanceVote/Component.tsx b/packages/stateful/actions/core/chain_governance/GovernanceVote/Component.tsx index 0d1eb1ed08..cbf7db1091 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceVote/Component.tsx +++ b/packages/stateful/actions/core/chain_governance/GovernanceVote/Component.tsx @@ -92,10 +92,9 @@ export const GovernanceVoteComponent: ActionComponent< value={proposal.proposalId.toString()} > #{proposal.proposalId.toString()} - {!!proposal.decodedContent && - 'title' in proposal.decodedContent && - typeof proposal.decodedContent.title === 'string' && - ' ' + proposal.decodedContent.title} + {'title' in proposal.decodedContent.value && + typeof proposal.decodedContent.value.title === 'string' && + ' ' + proposal.decodedContent.value.title} </option> ))} </SelectInput> From 728b113ff2bb4575961fd6a49d115cff5bd0b93f Mon Sep 17 00:00:00 2001 From: Noah Saso <noahsaso@gmail.com> Date: Tue, 11 Jul 2023 10:13:44 +0200 Subject: [PATCH 07/20] Don't show status on gov prop. --- .../GovernanceProposal/Component.tsx | 1 - .../GovernanceProposal/index.tsx | 4 +- .../proposal/GovernanceProposal.tsx | 119 +++++++++--------- 3 files changed, 61 insertions(+), 63 deletions(-) diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx b/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx index 09cb92354d..9bb5efb7db 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx @@ -352,7 +352,6 @@ export const GovernanceProposalComponent: ActionComponent< }, }} deposit={data.deposit} - status="pending" /> )} </> diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx b/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx index ebdbef7776..a2fb5e042c 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx @@ -190,7 +190,7 @@ export const makeGovernanceProposalAction: ActionMaker< description, amount: spends.map(({ amount, denom }) => ({ denom, - amount: amount.toString(), + amount: BigInt(amount).toString(), })), recipient: spendRecipient, } as CommunityPoolSpendProposal) @@ -219,7 +219,7 @@ export const makeGovernanceProposalAction: ActionMaker< value: { content, initialDeposit: deposit.map(({ amount, denom }) => ({ - amount: amount.toString(), + amount: BigInt(amount).toString(), denom, })), proposer: address, diff --git a/packages/stateless/components/proposal/GovernanceProposal.tsx b/packages/stateless/components/proposal/GovernanceProposal.tsx index 165d957833..aacdec6138 100644 --- a/packages/stateless/components/proposal/GovernanceProposal.tsx +++ b/packages/stateless/components/proposal/GovernanceProposal.tsx @@ -34,7 +34,7 @@ import { export type GovernanceProposalProps = { // Defined if created. Adds external link to proposal and displays ID. id?: string - status: ProposalStatus | 'pending' + status?: ProposalStatus | 'pending' content: GovProposalWithDecodedContent['decodedContent'] deposit: Coin[] startDate?: Date @@ -69,6 +69,61 @@ export const GovernanceProposal = ({ ? content.value.description : undefined + const info = [ + ...(status + ? ([ + { + Icon: RotateRightOutlined, + label: t('title.status'), + Value: (props) => ( + <p {...props}>{t(PROPOSAL_STATUS_I18N_KEY_MAP[status])}</p> + ), + }, + ] as ProposalStatusAndInfoProps['info']) + : []), + ...(startDate + ? ([ + { + Icon: TimelapseRounded, + label: t('title.votingOpened'), + Value: (props) => ( + <p {...props}> + {typeof startDate === 'string' + ? startDate + : formatDateTimeTz(startDate)} + </p> + ), + }, + ] as ProposalStatusAndInfoProps['info']) + : []), + // If open for voting, show relative time until end. + ...(endDate + ? status === ProposalStatus.PROPOSAL_STATUS_VOTING_PERIOD || + status === ProposalStatus.PROPOSAL_STATUS_DEPOSIT_PERIOD + ? ([ + { + Icon: HourglassTopRounded, + label: t('title.timeLeft'), + Value: (props) => ( + <Tooltip title={endDate}> + <p {...props}> + <TimeAgo date={endDate} formatter={timeAgoFormatter} /> + </p> + </Tooltip> + ), + }, + ] as ProposalStatusAndInfoProps['info']) + : // If closed, show end date. + ([ + { + Icon: TimerRounded, + label: t('title.votingClosed'), + Value: (props) => <p {...props}>{formatDateTimeTz(endDate)}</p>, + }, + ] as ProposalStatusAndInfoProps['info']) + : []), + ] + return ( <div className="flex flex-col gap-6"> <div className="flex flex-row items-center gap-4"> @@ -86,65 +141,9 @@ export const GovernanceProposal = ({ )} </div> - <ProposalStatusAndInfo - className="max-w-max" - info={[ - { - Icon: RotateRightOutlined, - label: t('title.status'), - Value: (props) => ( - <p {...props}>{t(PROPOSAL_STATUS_I18N_KEY_MAP[status])}</p> - ), - }, - ...(startDate - ? ([ - { - Icon: TimelapseRounded, - label: t('title.votingOpened'), - Value: (props) => ( - <p {...props}> - {typeof startDate === 'string' - ? startDate - : formatDateTimeTz(startDate)} - </p> - ), - }, - ] as ProposalStatusAndInfoProps['info']) - : []), - // If open for voting, show relative time until end. - ...(endDate - ? status === ProposalStatus.PROPOSAL_STATUS_VOTING_PERIOD || - status === ProposalStatus.PROPOSAL_STATUS_DEPOSIT_PERIOD - ? ([ - { - Icon: HourglassTopRounded, - label: t('title.timeLeft'), - Value: (props) => ( - <Tooltip title={endDate}> - <p {...props}> - <TimeAgo - date={endDate} - formatter={timeAgoFormatter} - /> - </p> - </Tooltip> - ), - }, - ] as ProposalStatusAndInfoProps['info']) - : // If closed, show end date. - ([ - { - Icon: TimerRounded, - label: t('title.votingClosed'), - Value: (props) => ( - <p {...props}>{formatDateTimeTz(endDate)}</p> - ), - }, - ] as ProposalStatusAndInfoProps['info']) - : []), - ]} - inline - /> + {info.length > 0 && ( + <ProposalStatusAndInfo className="max-w-max" info={info} inline /> + )} {!!description && ( <MarkdownRenderer From 987f8af0f55f5b49b80b2bc55729e8b4f9a0370f Mon Sep 17 00:00:00 2001 From: Noah Saso <noahsaso@gmail.com> Date: Tue, 11 Jul 2023 10:14:51 +0200 Subject: [PATCH 08/20] Fixed translation. --- packages/stateless/components/proposal/GovernanceProposal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stateless/components/proposal/GovernanceProposal.tsx b/packages/stateless/components/proposal/GovernanceProposal.tsx index aacdec6138..6ca610d825 100644 --- a/packages/stateless/components/proposal/GovernanceProposal.tsx +++ b/packages/stateless/components/proposal/GovernanceProposal.tsx @@ -170,7 +170,7 @@ export const GovernanceProposal = ({ GovernanceProposalType.CommunityPoolSpendProposal && ( <div className="space-y-3"> <p className="text-text-tertiary"> - {t('info.communityPoolSpendProposal')} + {t('govProposalType.CommunityPoolSpendProposal')} </p> <PayEntityDisplay From 9bf6557cf8dee505a9c46fbd23bcf76c0a7a8e92 Mon Sep 17 00:00:00 2001 From: Noah Saso <noahsaso@gmail.com> Date: Tue, 11 Jul 2023 10:22:05 +0200 Subject: [PATCH 09/20] Added image URL to stateful TokenAmountDisplay. --- packages/stateful/components/TokenAmountDisplay.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/stateful/components/TokenAmountDisplay.tsx b/packages/stateful/components/TokenAmountDisplay.tsx index a4f38fc167..1993cfb180 100644 --- a/packages/stateful/components/TokenAmountDisplay.tsx +++ b/packages/stateful/components/TokenAmountDisplay.tsx @@ -36,6 +36,10 @@ export const TokenAmountDisplay = ({ ? 0 : loadingGenericToken.data.decimals } + iconUrl={ + (!loadingGenericToken.loading && loadingGenericToken.data?.imageUrl) || + undefined + } symbol={ loadingGenericToken.loading || !loadingGenericToken.data ? '...' From 6bdd473380320b235eda10f58a5a79cb924166fc Mon Sep 17 00:00:00 2001 From: Noah Saso <noahsaso@gmail.com> Date: Tue, 11 Jul 2023 10:49:01 +0200 Subject: [PATCH 10/20] Decode nested protobufs in raw messages. --- .../components/MultipleChoiceOptionViewer.tsx | 12 ++++++--- .../ProposalInnerContentDisplay.tsx | 12 ++++----- packages/utils/messages/protobuf.ts | 27 +++++++++++++++++++ packages/utils/objectMatchesStructure.ts | 4 +-- 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/components/MultipleChoiceOptionViewer.tsx b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/components/MultipleChoiceOptionViewer.tsx index 962f763823..5b79ee2440 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/components/MultipleChoiceOptionViewer.tsx +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/components/MultipleChoiceOptionViewer.tsx @@ -1,6 +1,6 @@ import { AnalyticsOutlined, Check } from '@mui/icons-material' import clsx from 'clsx' -import { ComponentType, useState } from 'react' +import { ComponentType, useMemo, useState } from 'react' import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' @@ -14,6 +14,7 @@ import { } from '@dao-dao/stateless' import { SuspenseLoaderProps } from '@dao-dao/types' import { MultipleChoiceOptionType } from '@dao-dao/types/contracts/DaoProposalMultiple' +import { decodeNestedProtobufs } from '@dao-dao/utils' import { MultipleChoiceOptionData } from '../types' @@ -55,6 +56,11 @@ export const MultipleChoiceOptionViewer = ({ ) const toggleExpanded = () => setExpanded((e) => !e) + const rawDecodedMessages = useMemo( + () => JSON.stringify(decodedMessages.map(decodeNestedProtobufs), null, 2), + [decodedMessages] + ) + return ( <div className={clsx( @@ -117,9 +123,7 @@ export const MultipleChoiceOptionViewer = ({ {noMessages ? ( <p className="caption-text italic">{t('info.optionInert')}</p> ) : (forceRaw === undefined && showRaw) || forceRaw ? ( - <CosmosMessageDisplay - value={JSON.stringify(decodedMessages, undefined, 2)} - /> + <CosmosMessageDisplay value={rawDecodedMessages} /> ) : ( <ActionsRenderer SuspenseLoader={SuspenseLoader} diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/components/ProposalInnerContentDisplay.tsx b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/components/ProposalInnerContentDisplay.tsx index 49bb00a2ba..3a9d2e489a 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/components/ProposalInnerContentDisplay.tsx +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/components/ProposalInnerContentDisplay.tsx @@ -17,7 +17,7 @@ import { } from '@dao-dao/types' import { Proposal } from '@dao-dao/types/contracts/CwProposalSingle.v1' import { SingleChoiceProposal } from '@dao-dao/types/contracts/DaoProposalSingle.v2' -import { decodeMessages } from '@dao-dao/utils' +import { decodeMessages, decodeNestedProtobufs } from '@dao-dao/utils' import { SuspenseLoader } from '../../../../components' import { useLoadingProposal } from '../hooks' @@ -58,6 +58,10 @@ const InnerProposalInnerContentDisplay = ({ () => decodeMessages(proposal.msgs), [proposal.msgs] ) + const rawDecodedMessages = useMemo( + () => JSON.stringify(decodedMessages.map(decodeNestedProtobufs), null, 2), + [decodedMessages] + ) // If no msgs, set seen all action pages to true so that the user can vote. const [markedSeen, setMarkedSeen] = useState(false) @@ -134,11 +138,7 @@ const InnerProposalInnerContentDisplay = ({ </p> </Button> - {showRaw && ( - <CosmosMessageDisplay - value={JSON.stringify(decodedMessages, undefined, 2)} - /> - )} + {showRaw && <CosmosMessageDisplay value={rawDecodedMessages} />} </div> ) : null } diff --git a/packages/utils/messages/protobuf.ts b/packages/utils/messages/protobuf.ts index 1c7a676a5b..26088338de 100644 --- a/packages/utils/messages/protobuf.ts +++ b/packages/utils/messages/protobuf.ts @@ -616,3 +616,30 @@ export const isDecodedStargateMsg = (msg: any): msg is DecodedStargateMsg => value: {}, }, }) && typeof msg.stargate.value === 'object' + +// Decode any nested protobufs into JSON. +export const decodeNestedProtobufs = (msg: any): any => + typeof msg !== 'object' || msg === null + ? msg + : Array.isArray(msg) + ? msg.map(decodeNestedProtobufs) + : Object.entries(msg).reduce((acc, [key, value]) => { + let decodedValue = value + try { + if ( + objectMatchesStructure<Any>(value, { + typeUrl: {}, + value: {}, + }) && + typeof value.typeUrl === 'string' && + value.value instanceof Uint8Array + ) { + decodedValue = decodeRawProtobufMsg(value) + } + } catch {} + + return { + ...acc, + [key]: decodeNestedProtobufs(decodedValue), + } + }, {} as Record<string, any>) diff --git a/packages/utils/objectMatchesStructure.ts b/packages/utils/objectMatchesStructure.ts index e8b06aacec..08e2772c6b 100644 --- a/packages/utils/objectMatchesStructure.ts +++ b/packages/utils/objectMatchesStructure.ts @@ -4,7 +4,7 @@ type Structure = { } // Check if object contains the expected structure. -export const objectMatchesStructure = ( +export const objectMatchesStructure = <T extends Record<string, any>>( object: any | undefined | null, structure: Structure, options: { @@ -13,7 +13,7 @@ export const objectMatchesStructure = ( } = { ignoreNullUndefined: true, } -): boolean => { +): object is T => { if (!object || typeof object !== 'object' || Array.isArray(object)) { return false } From 866b31e147611c06d1b2c660045d570ae4118712 Mon Sep 17 00:00:00 2001 From: Noah Saso <noahsaso@gmail.com> Date: Tue, 11 Jul 2023 10:55:32 +0200 Subject: [PATCH 11/20] Fixed types. --- packages/utils/messages/protobuf.ts | 8 ++++---- packages/utils/objectMatchesStructure.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/utils/messages/protobuf.ts b/packages/utils/messages/protobuf.ts index 26088338de..82708ae055 100644 --- a/packages/utils/messages/protobuf.ts +++ b/packages/utils/messages/protobuf.ts @@ -627,14 +627,14 @@ export const decodeNestedProtobufs = (msg: any): any => let decodedValue = value try { if ( - objectMatchesStructure<Any>(value, { + objectMatchesStructure(value, { typeUrl: {}, value: {}, }) && - typeof value.typeUrl === 'string' && - value.value instanceof Uint8Array + typeof (value as Any).typeUrl === 'string' && + (value as Any).value instanceof Uint8Array ) { - decodedValue = decodeRawProtobufMsg(value) + decodedValue = decodeRawProtobufMsg(value as Any) } } catch {} diff --git a/packages/utils/objectMatchesStructure.ts b/packages/utils/objectMatchesStructure.ts index 08e2772c6b..e8b06aacec 100644 --- a/packages/utils/objectMatchesStructure.ts +++ b/packages/utils/objectMatchesStructure.ts @@ -4,7 +4,7 @@ type Structure = { } // Check if object contains the expected structure. -export const objectMatchesStructure = <T extends Record<string, any>>( +export const objectMatchesStructure = ( object: any | undefined | null, structure: Structure, options: { @@ -13,7 +13,7 @@ export const objectMatchesStructure = <T extends Record<string, any>>( } = { ignoreNullUndefined: true, } -): object is T => { +): boolean => { if (!object || typeof object !== 'object' || Array.isArray(object)) { return false } From 6df1061e2fae1a4a0a3ed05cf0edddc2c9756ab0 Mon Sep 17 00:00:00 2001 From: Noah Saso <noahsaso@gmail.com> Date: Wed, 12 Jul 2023 08:43:15 +0200 Subject: [PATCH 12/20] Use decode nested protobufs during preview. --- .../components/actions/RawActionsRenderer.tsx | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/stateless/components/actions/RawActionsRenderer.tsx b/packages/stateless/components/actions/RawActionsRenderer.tsx index a6aa008af6..1391e18e62 100644 --- a/packages/stateless/components/actions/RawActionsRenderer.tsx +++ b/packages/stateless/components/actions/RawActionsRenderer.tsx @@ -1,9 +1,11 @@ +import { useMemo } from 'react' + import { CategorizedActionKeyAndData, LoadedActions, PartialCategorizedActionKeyAndData, } from '@dao-dao/types' -import { convertActionsToMessages, decodedMessagesString } from '@dao-dao/utils' +import { convertActionsToMessages, decodeNestedProtobufs } from '@dao-dao/utils' import { CosmosMessageDisplay } from '../CosmosMessageDisplay' @@ -20,12 +22,18 @@ export type RawActionsRendererProps = { export const RawActionsRenderer = ({ actionData, loadedActions, -}: RawActionsRendererProps) => ( - <CosmosMessageDisplay - value={decodedMessagesString( - convertActionsToMessages(loadedActions, actionData, { - throwErrors: false, - }) - )} - /> -) +}: RawActionsRendererProps) => { + const rawDecodedMessages = useMemo( + () => + JSON.stringify( + convertActionsToMessages(loadedActions, actionData, { + throwErrors: false, + }).map(decodeNestedProtobufs), + null, + 2 + ), + [loadedActions, actionData] + ) + + return <CosmosMessageDisplay value={rawDecodedMessages} /> +} From 56746c72aa88387ade7bcabba32f5710895117e8 Mon Sep 17 00:00:00 2001 From: Noah Saso <noahsaso@gmail.com> Date: Wed, 12 Jul 2023 11:10:50 +0200 Subject: [PATCH 13/20] Fixed spend gov prop amount display. --- .../GovernanceProposal/Component.tsx | 25 +++++++++++-------- .../GovernanceProposal/README.md | 2 +- .../GovernanceProposal/index.tsx | 12 ++++++--- .../stateful/components/PayEntityDisplay.tsx | 5 +++- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx b/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx index 9bb5efb7db..5ddd6e8208 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx @@ -1,4 +1,3 @@ -import { Coin } from '@cosmjs/amino' import { Check, Close } from '@mui/icons-material' import { ComponentType } from 'react' import { useFieldArray, useFormContext } from 'react-hook-form' @@ -35,8 +34,6 @@ import { validateRequired, } from '@dao-dao/utils' -import { useActionOptions } from '../../../react/context' - export type GovernanceProposalOptions = { minDeposits: LoadingData<GenericTokenBalance[]> PayEntityDisplay: ComponentType<StatefulPayEntityDisplayProps> @@ -48,9 +45,15 @@ export type GovernanceProposalData = { type: GovernanceProposalType title: string description: string - deposit: Coin[] + deposit: { + amount: number + denom: string + }[] // GovernanceProposalType.CommunityPoolSpendProposal - spends: Coin[] + spends: { + amount: number + denom: string + }[] spendRecipient: string // GovernanceProposalType.ParameterChangeProposal parameterChanges: string @@ -68,7 +71,6 @@ export const GovernanceProposalComponent: ActionComponent< options: { minDeposits, PayEntityDisplay, TokenAmountDisplay, AddressInput }, data, }) => { - const { address } = useActionOptions() const { t } = useTranslation() const { register, setValue, watch, control } = useFormContext<GovernanceProposalData>() @@ -262,7 +264,7 @@ export const GovernanceProposalComponent: ActionComponent< className="self-start" onClick={() => appendSpend({ - amount: '1', + amount: 1, denom: NATIVE_TOKEN.denomOrAddress, }) } @@ -336,8 +338,8 @@ export const GovernanceProposalComponent: ActionComponent< ...(data.type === GovernanceProposalType.CommunityPoolSpendProposal && { - amount: data.deposit, - recipient: address, + amount: data.spends, + recipient: data.spendRecipient, }), ...(data.type === @@ -351,7 +353,10 @@ export const GovernanceProposalComponent: ActionComponent< }), }, }} - deposit={data.deposit} + deposit={data.deposit.map(({ denom, amount }) => ({ + denom, + amount: BigInt(amount).toString(), + }))} /> )} </> diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/README.md b/packages/stateful/actions/core/chain_governance/GovernanceProposal/README.md index 4dd7e73fad..4389440929 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceProposal/README.md +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/README.md @@ -27,7 +27,7 @@ guide](https://github.com/DA0-DA0/dao-dao-ui/wiki/Bulk-importing-actions). "spends": [ { "denom": "<DENOM>", - "amount": "<AMOUNT>" + "amount": <AMOUNT> }, ... ], diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx b/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx index a2fb5e042c..e4709ef355 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx @@ -1,3 +1,4 @@ +import { Coin } from '@cosmjs/stargate' import { MsgSubmitProposal } from 'cosmjs-types/cosmos/gov/v1beta1/tx' import { ParameterChangeProposal } from 'cosmjs-types/cosmos/params/v1beta1/params' import { SoftwareUpgradeProposal } from 'cosmjs-types/cosmos/upgrade/v1beta1/upgrade' @@ -148,7 +149,7 @@ export const makeGovernanceProposalAction: ActionMaker< amount: convertMicroDenomToDenomWithDecimals( deposit.amount, minDeposits.data[0].decimals - ).toString(), + ), }, ] : [], @@ -157,7 +158,7 @@ export const makeGovernanceProposalAction: ActionMaker< ? [ { denom: deposit.denom, - amount: '1000', + amount: 1000, }, ] : [], @@ -266,7 +267,12 @@ export const makeGovernanceProposalAction: ActionMaker< spends: decodedContent.typeUrl === GovernanceProposalType.CommunityPoolSpendProposal - ? decodedContent.value.amount + ? (decodedContent.value.amount as Coin[]).map( + ({ amount, denom }) => ({ + amount: Number(amount), + denom, + }) + ) : [], spendRecipient: decodedContent.typeUrl === diff --git a/packages/stateful/components/PayEntityDisplay.tsx b/packages/stateful/components/PayEntityDisplay.tsx index e70c904a06..d4df8e267c 100644 --- a/packages/stateful/components/PayEntityDisplay.tsx +++ b/packages/stateful/components/PayEntityDisplay.tsx @@ -35,7 +35,10 @@ export const PayEntityDisplay = ({ <StatelessPayEntityDisplay {...props} EntityDisplay={EntityDisplay} - tokens={tokenBalances.data} + tokens={tokenBalances.data.map(({ token }, index) => ({ + token, + balance: coins[index].amount, + }))} /> ) } From 196c731509e0b8456a5f898960e8eca39baddd12 Mon Sep 17 00:00:00 2001 From: Noah Saso <noahsaso@gmail.com> Date: Wed, 12 Jul 2023 11:14:43 +0200 Subject: [PATCH 14/20] Remove min from initial deposit. --- .../chain_governance/GovernanceProposal/Component.stories.tsx | 4 ++-- .../core/chain_governance/GovernanceProposal/Component.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.stories.tsx b/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.stories.tsx index 313311907f..3ac74076ac 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.stories.tsx +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.stories.tsx @@ -31,13 +31,13 @@ Default.args = { 'Full details on the testnets github. Target binary is v10.0.0-alpha.2', deposit: [ { - amount: '500000000', + amount: 100, denom: 'ujunox', }, ], spends: [ { - amount: '100000000', + amount: 1, denom: 'ujunox', }, ], diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx b/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx index 5ddd6e8208..7772eaa323 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx @@ -159,7 +159,7 @@ export const GovernanceProposalComponent: ActionComponent< (fieldNamePrefix + 'deposit.0.amount') as 'deposit.0.amount' } amountMin={convertMicroDenomToDenomWithDecimals( - selectedMinDepositToken?.balance ?? 0, + 1, selectedMinDepositToken?.token.decimals ?? 0 )} amountStep={convertMicroDenomToDenomWithDecimals( From 865e7e6cb2317f3d76a8a5ab81441b5e6955f05f Mon Sep 17 00:00:00 2001 From: Noah Saso <noahsaso@gmail.com> Date: Wed, 12 Jul 2023 11:15:36 +0200 Subject: [PATCH 15/20] Fixed README. --- .../actions/core/chain_governance/GovernanceProposal/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/README.md b/packages/stateful/actions/core/chain_governance/GovernanceProposal/README.md index 4389440929..1656d584ac 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceProposal/README.md +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/README.md @@ -21,7 +21,7 @@ guide](https://github.com/DA0-DA0/dao-dao-ui/wiki/Bulk-importing-actions). "deposit": [ { "denom": "<DENOM>", - "amount": "<AMOUNT>" + "amount": <AMOUNT> } ], "spends": [ From d598c8c70543f49f8e3e11eb9d21dfb86d09ef3d Mon Sep 17 00:00:00 2001 From: Noah Saso <noahsaso@gmail.com> Date: Wed, 12 Jul 2023 11:45:34 +0200 Subject: [PATCH 16/20] Added governance deposit action. --- packages/i18n/locales/en/translation.json | 5 + .../GovernanceDeposit/Component.stories.tsx | 82 +++++++ .../GovernanceDeposit/Component.tsx | 150 ++++++++++++ .../GovernanceDeposit/README.md | 26 +++ .../GovernanceDeposit/index.tsx | 221 ++++++++++++++++++ .../actions/core/chain_governance/index.ts | 2 + packages/stateless/components/emoji.tsx | 4 + .../proposal/GovernanceProposal.tsx | 14 +- packages/types/actions.ts | 1 + 9 files changed, 499 insertions(+), 6 deletions(-) create mode 100644 packages/stateful/actions/core/chain_governance/GovernanceDeposit/Component.stories.tsx create mode 100644 packages/stateful/actions/core/chain_governance/GovernanceDeposit/Component.tsx create mode 100644 packages/stateful/actions/core/chain_governance/GovernanceDeposit/README.md create mode 100644 packages/stateful/actions/core/chain_governance/GovernanceDeposit/index.tsx diff --git a/packages/i18n/locales/en/translation.json b/packages/i18n/locales/en/translation.json index c0203b676e..25dbf14690 100644 --- a/packages/i18n/locales/en/translation.json +++ b/packages/i18n/locales/en/translation.json @@ -204,6 +204,7 @@ "emoji": { "baby": "Baby", "ballotBox": "Ballot box", + "bank": "Bank", "bee": "Bee", "box": "Box", "brokenHeart": "Broken heart", @@ -403,6 +404,7 @@ "dates": "Dates", "defaultTierName": "Core contributors", "delegatorAddress": "Delegator address", + "deposit": "Deposit", "description": "Description", "descriptionOptional": "Description (optional)", "dragFileHereOrClick": "Drag file here or <2>click</2> to select.", @@ -662,6 +664,7 @@ "depositFiatDescription": "Buy $USDC with a card or bank account, and it will be sent directly to the DAO's treasury. Balances may take some time to update once the Kado transaction completes.", "depositFromStargazeQuestion": "Deposit NFTs from Stargaze?", "depositNftsModalSubtitle": "Select the NFTs you want to deposit into {{daoName}} from your Stargaze wallet.", + "depositToGovernanceProposalDescription": "Deposit to a chain governance proposal in the deposit period.", "depositTokenSubtitle": "Tokens will be sent from your wallet.", "depositTokenWarning": "Tokens will be sent from your wallet to the DAO. You cannot undo a deposit. You will not gain voting power by depositing tokens.", "disabled": "Disabled", @@ -747,6 +750,7 @@ "noDaosFollowedYet": "You have not yet followed any DAOs.", "noDisplayName": "no display name", "noFundsDuringInstantiation": "No funds will be transferred during instantiation.", + "noGovernanceProposalsNeedDeposit": "There are no governance proposals that need deposits.", "noGovernanceProposalsOpenForVoting": "There are no governance proposals open for voting.", "noNftsFound": "No NFTs found.", "noNftsYet": "No NFTs to appreciate yet.", @@ -1071,6 +1075,7 @@ "depositFiat": "Deposit fiat", "depositNfts": "Import NFTs", "depositRefunds": "Deposit refunds", + "depositToGovernanceProposal": "Deposit to Governance Proposal", "depositToken": "Deposit ${{tokenSymbol}}", "deposits": "Deposits", "description": "Description", diff --git a/packages/stateful/actions/core/chain_governance/GovernanceDeposit/Component.stories.tsx b/packages/stateful/actions/core/chain_governance/GovernanceDeposit/Component.stories.tsx new file mode 100644 index 0000000000..78cf7a25e0 --- /dev/null +++ b/packages/stateful/actions/core/chain_governance/GovernanceDeposit/Component.stories.tsx @@ -0,0 +1,82 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react' +import { cosmos } from 'interchain-rpc' +import Long from 'long' + +import { ReactHookFormDecorator } from '@dao-dao/storybook' +import { GovProposalWithDecodedContent } from '@dao-dao/types' + +import { PayEntityDisplay } from '../../../../components/PayEntityDisplay' +import { TokenAmountDisplay } from '../../../../components/TokenAmountDisplay' +import { GovernanceDepositComponent } from './Component' + +export default { + title: + 'DAO DAO / packages / stateful / actions / core / chain_governance / GovernanceDeposit', + component: GovernanceDepositComponent, + decorators: [ReactHookFormDecorator], +} as ComponentMeta<typeof GovernanceDepositComponent> + +const Template: ComponentStory<typeof GovernanceDepositComponent> = (args) => ( + <GovernanceDepositComponent {...args} /> +) + +const { ProposalStatus } = cosmos.gov.v1beta1 + +const makeProposal = (): GovProposalWithDecodedContent => ({ + proposalId: Long.fromInt(1), + content: {} as any, + decodedContent: { + '@type': '/cosmos.upgrade.v1beta1.SoftwareUpgradeProposal', + title: 'Upgrade to v10 Alpha 1', + description: + 'Full details on the testnets github. Target binary is v10.0.0-alpha.2', + plan: { + name: 'v10', + time: '0001-01-01T00:00:00Z', + height: '20000', + info: '', + upgraded_client_state: null, + }, + } as any, + status: ProposalStatus.PROPOSAL_STATUS_DEPOSIT_PERIOD, + finalTallyResult: { + yes: '54076995000', + abstain: '0', + no: '0', + noWithVeto: '0', + }, + submitTime: new Date('2022-09-23T22:56:17.961690524Z'), + depositEndTime: new Date('2022-10-03T22:56:17.961690524Z'), + totalDeposit: [ + { + denom: 'ujunox', + amount: '500000000', + }, + ], + votingStartTime: new Date('2022-09-23T22:56:17.961690524Z'), + votingEndTime: new Date('2022-09-24T10:56:17.961690524Z'), +}) + +export const Default = Template.bind({}) +Default.args = { + fieldNamePrefix: '', + allActionsWithData: [], + index: 0, + data: { + proposalId: '', + deposit: [ + { + denom: 'JUNOX', + amount: 1, + }, + ], + }, + isCreating: true, + errors: {}, + options: { + proposals: [makeProposal(), makeProposal(), makeProposal(), makeProposal()], + depositTokens: { loading: false, data: [] }, + PayEntityDisplay, + TokenAmountDisplay, + }, +} diff --git a/packages/stateful/actions/core/chain_governance/GovernanceDeposit/Component.tsx b/packages/stateful/actions/core/chain_governance/GovernanceDeposit/Component.tsx new file mode 100644 index 0000000000..9bcf3a1663 --- /dev/null +++ b/packages/stateful/actions/core/chain_governance/GovernanceDeposit/Component.tsx @@ -0,0 +1,150 @@ +import { CheckBoxOutlineBlankRounded } from '@mui/icons-material' +import { ComponentType } from 'react' +import { useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' + +import { + GovernanceProposal, + InputLabel, + NoContent, + SelectInput, + TokenInput, +} from '@dao-dao/stateless' +import { + GenericToken, + GovProposalWithDecodedContent, + LoadingData, + StatefulPayEntityDisplayProps, + StatefulTokenAmountDisplayProps, +} from '@dao-dao/types' +import { ActionComponent } from '@dao-dao/types/actions' +import { + convertMicroDenomToDenomWithDecimals, + validateRequired, +} from '@dao-dao/utils' + +export type GovernanceDepositOptions = { + proposals: GovProposalWithDecodedContent[] + depositTokens: LoadingData<GenericToken[]> + PayEntityDisplay: ComponentType<StatefulPayEntityDisplayProps> + TokenAmountDisplay: ComponentType<StatefulTokenAmountDisplayProps> +} + +export type GovernanceDepositData = { + proposalId: string + deposit: { + amount: number + denom: string + }[] +} + +export const GovernanceDepositComponent: ActionComponent< + GovernanceDepositOptions, + GovernanceDepositData +> = ({ + fieldNamePrefix, + errors, + isCreating, + options: { depositTokens, proposals, PayEntityDisplay, TokenAmountDisplay }, + data, +}) => { + const { t } = useTranslation() + const { setValue, register, watch } = useFormContext<GovernanceDepositData>() + + const proposalId = watch((fieldNamePrefix + 'proposalId') as 'proposalId') + const proposalSelected = proposals.find( + (p) => p.proposalId.toString() === proposalId + ) + + const selectedDepositToken = + depositTokens.loading || !data.deposit.length + ? undefined + : depositTokens.data.find( + ({ denomOrAddress }) => denomOrAddress === data.deposit[0].denom + ) + + return ( + <> + {isCreating && + (proposals.length === 0 ? ( + <NoContent + Icon={CheckBoxOutlineBlankRounded} + body={t('info.noGovernanceProposalsNeedDeposit')} + error + /> + ) : ( + <SelectInput + containerClassName="mb-4" + error={errors?.proposalId} + fieldName={(fieldNamePrefix + 'proposalId') as 'proposalId'} + register={register} + validation={[validateRequired]} + > + {proposals.map((proposal) => ( + <option + key={proposal.proposalId.toString()} + value={proposal.proposalId.toString()} + > + #{proposal.proposalId.toString()} + {'title' in proposal.decodedContent.value && + typeof proposal.decodedContent.value.title === 'string' && + ' ' + proposal.decodedContent.value.title} + </option> + ))} + </SelectInput> + ))} + + {proposalSelected ? ( + <GovernanceProposal + PayEntityDisplay={PayEntityDisplay} + TokenAmountDisplay={TokenAmountDisplay} + className="rounded-md border border-border-primary p-4" + content={proposalSelected.decodedContent} + deposit={proposalSelected.totalDeposit} + endDate={proposalSelected.votingEndTime} + id={proposalSelected.proposalId.toString()} + startDate={proposalSelected.votingStartTime} + status={proposalSelected.status} + /> + ) : ( + // If not creating and no proposal selected, something went wrong. + !isCreating && ( + <p className="text-text-interactive-error"> + {t('error.failedToFindGovernanceProposal', { id: proposalId })} + </p> + ) + )} + + <div className="space-y-1"> + <InputLabel name={t('form.deposit')} /> + <TokenInput + amountError={errors?.deposit?.[0]?.amount} + amountFieldName={ + (fieldNamePrefix + 'deposit.0.amount') as 'deposit.0.amount' + } + amountMin={convertMicroDenomToDenomWithDecimals( + 1, + selectedDepositToken?.decimals ?? 0 + )} + amountStep={convertMicroDenomToDenomWithDecimals( + 1, + selectedDepositToken?.decimals ?? 0 + )} + convertMicroDenom + onSelectToken={({ denomOrAddress }) => + setValue( + (fieldNamePrefix + 'deposit.0.denom') as 'deposit.0.denom', + denomOrAddress + ) + } + readOnly={!isCreating} + register={register} + selectedToken={selectedDepositToken} + setValue={setValue} + tokens={depositTokens} + watch={watch} + /> + </div> + </> + ) +} diff --git a/packages/stateful/actions/core/chain_governance/GovernanceDeposit/README.md b/packages/stateful/actions/core/chain_governance/GovernanceDeposit/README.md new file mode 100644 index 0000000000..013f85e821 --- /dev/null +++ b/packages/stateful/actions/core/chain_governance/GovernanceDeposit/README.md @@ -0,0 +1,26 @@ +# GovernanceDeposit + +Deposit to a chain governance proposal. + +## Bulk import format + +This is relevant when bulk importing actions, as described in [this +guide](https://github.com/DA0-DA0/dao-dao-ui/wiki/Bulk-importing-actions). + +### Key + +`governanceDeposit` + +### Data format + +```json +{ + "proposalId": "<PROPOSAL ID>", + "deposit": [ + { + "denom": "<DENOM>", + "amount": <AMOUNT> + } + ] +} +``` diff --git a/packages/stateful/actions/core/chain_governance/GovernanceDeposit/index.tsx b/packages/stateful/actions/core/chain_governance/GovernanceDeposit/index.tsx new file mode 100644 index 0000000000..7cc21a199b --- /dev/null +++ b/packages/stateful/actions/core/chain_governance/GovernanceDeposit/index.tsx @@ -0,0 +1,221 @@ +import { Coin } from '@cosmjs/stargate' +import { ProposalStatus } from 'cosmjs-types/cosmos/gov/v1beta1/gov' +import { MsgDeposit } from 'cosmjs-types/cosmos/gov/v1beta1/tx' +import Long from 'long' +import { useCallback, useEffect } from 'react' +import { useFormContext } from 'react-hook-form' +import { constSelector, useRecoilValue, waitForAll } from 'recoil' + +import { + genericTokenSelector, + govProposalSelector, + govProposalsSelector, + govQueryParamsSelector, +} from '@dao-dao/state' +import { BankEmoji, useCachedLoading } from '@dao-dao/stateless' +import { TokenType } from '@dao-dao/types' +import { + ActionComponent, + ActionKey, + ActionMaker, + UseDecodedCosmosMsg, + UseDefaults, + UseTransformToCosmos, +} from '@dao-dao/types/actions' +import { + isDecodedStargateMsg, + makeStargateMessage, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { PayEntityDisplay } from '../../../../components/PayEntityDisplay' +import { TokenAmountDisplay } from '../../../../components/TokenAmountDisplay' +import { useActionOptions } from '../../../react' +import { + GovernanceDepositData, + GovernanceDepositComponent as StatelessGovernanceDepositComponent, +} from './Component' + +const useDefaults: UseDefaults<GovernanceDepositData> = () => ({ + proposalId: '', + deposit: [], +}) + +const Component: ActionComponent<undefined, GovernanceDepositData> = ( + props +) => { + const { isCreating, fieldNamePrefix } = props + const { chainId } = useActionOptions() + const { watch, setValue, setError, clearErrors } = + useFormContext<GovernanceDepositData>() + + const proposalId = watch( + (props.fieldNamePrefix + 'proposalId') as 'proposalId' + ) + + const proposalOptions = useRecoilValue( + isCreating + ? govProposalsSelector({ + status: ProposalStatus.PROPOSAL_STATUS_DEPOSIT_PERIOD, + chainId, + }) + : constSelector(undefined) + ) + + // Prevent action from being submitted if there are no open proposals. + useEffect(() => { + if (proposalOptions && proposalOptions.length === 0) { + setError((fieldNamePrefix + 'proposalId') as 'proposalId', { + type: 'manual', + }) + } else { + clearErrors((fieldNamePrefix + 'proposalId') as 'proposalId') + } + }, [proposalOptions, setError, clearErrors, fieldNamePrefix]) + + // If viewing an action where we already selected and voted on a proposal, + // load just the one we voted on and add it to the list so we can display it. + const selectedProposal = useRecoilValue( + !isCreating && proposalId + ? govProposalSelector({ + proposalId: Number(proposalId), + chainId, + }) + : constSelector(undefined) + ) + + const govParams = useRecoilValue( + govQueryParamsSelector({ + chainId, + }) + ) + + // On proposal change, update deposit to remaining needed. + useEffect(() => { + const proposalSelected = + proposalId && + proposalOptions?.find((p) => p.proposalId.toString() === proposalId) + if (!proposalSelected) { + return + } + + const minDeposit = govParams.depositParams.minDeposit[0] + const missingDeposit = + BigInt(minDeposit.amount) - + BigInt( + proposalSelected.totalDeposit.find( + ({ denom }) => minDeposit.denom === denom + )?.amount ?? 0 + ) + + if (missingDeposit > 0) { + setValue((fieldNamePrefix + 'deposit') as 'deposit', [ + { + denom: minDeposit.denom, + amount: Number(missingDeposit), + }, + ]) + } + }, [proposalId, proposalOptions, govParams, setValue, fieldNamePrefix]) + + // Select first proposal once loaded if nothing selected. + useEffect(() => { + if (isCreating && proposalOptions?.length && !proposalId) { + setValue( + (fieldNamePrefix + 'proposalId') as 'proposalId', + proposalOptions[0].proposalId.toString() + ) + } + }, [isCreating, proposalOptions, proposalId, setValue, fieldNamePrefix]) + + const depositTokens = useCachedLoading( + waitForAll( + govParams.depositParams.minDeposit.map(({ denom }) => + genericTokenSelector({ + type: TokenType.Native, + denomOrAddress: denom, + }) + ) + ), + [] + ) + + return ( + <StatelessGovernanceDepositComponent + {...props} + options={{ + depositTokens, + proposals: [ + ...(proposalOptions ?? []), + ...(selectedProposal ? [selectedProposal] : []), + ], + PayEntityDisplay, + TokenAmountDisplay, + }} + /> + ) +} + +export const makeGovernanceDepositAction: ActionMaker< + GovernanceDepositData +> = ({ t, address }) => { + const useTransformToCosmos: UseTransformToCosmos< + GovernanceDepositData + > = () => + useCallback( + ({ proposalId, deposit }) => + makeStargateMessage({ + stargate: { + typeUrl: '/cosmos.gov.v1beta1.MsgDeposit', + value: { + proposalId: Long.fromString(proposalId || '-1'), + depositor: address, + amount: deposit.map(({ denom, amount }) => ({ + denom, + amount: BigInt(amount).toString(), + })), + } as MsgDeposit, + }, + }), + [] + ) + + const useDecodedCosmosMsg: UseDecodedCosmosMsg<GovernanceDepositData> = ( + msg: Record<string, any> + ) => + isDecodedStargateMsg(msg) && + objectMatchesStructure(msg.stargate.value, { + proposalId: {}, + depositor: {}, + amount: {}, + }) && + // Make sure this is a deposit message. + msg.stargate.typeUrl === '/cosmos.gov.v1beta1.MsgDeposit' && + msg.stargate.value.depositor === address + ? { + match: true, + data: { + proposalId: msg.stargate.value.proposalId.toString(), + deposit: (msg.stargate.value.amount as Coin[]).map( + ({ denom, amount }) => ({ + denom, + amount: Number(amount), + }) + ), + }, + } + : { + match: false, + } + + return { + key: ActionKey.GovernanceDeposit, + Icon: BankEmoji, + label: t('title.depositToGovernanceProposal'), + description: t('info.depositToGovernanceProposalDescription'), + Component, + useDefaults, + useTransformToCosmos, + useDecodedCosmosMsg, + } +} diff --git a/packages/stateful/actions/core/chain_governance/index.ts b/packages/stateful/actions/core/chain_governance/index.ts index 02285cb5bd..def274a188 100644 --- a/packages/stateful/actions/core/chain_governance/index.ts +++ b/packages/stateful/actions/core/chain_governance/index.ts @@ -1,5 +1,6 @@ import { ActionCategoryKey, ActionCategoryMaker } from '@dao-dao/types' +import { makeGovernanceDepositAction } from './GovernanceDeposit' import { makeGovernanceProposalAction } from './GovernanceProposal' import { makeGovernanceVoteAction } from './GovernanceVote' import { makeValidatorActionsAction } from './ValidatorActions' @@ -13,6 +14,7 @@ export const makeChainGovernanceActionCategory: ActionCategoryMaker = ({ actionMakers: [ makeGovernanceVoteAction, makeGovernanceProposalAction, + makeGovernanceDepositAction, makeValidatorActionsAction, ], }) diff --git a/packages/stateless/components/emoji.tsx b/packages/stateless/components/emoji.tsx index 68a4b9c12a..d326eecdf0 100644 --- a/packages/stateless/components/emoji.tsx +++ b/packages/stateless/components/emoji.tsx @@ -55,6 +55,10 @@ export const MoneyWingsEmoji = () => ( <EmojiWrapper emoji="💸" labelI18nKey="emoji.moneyWings" /> ) +export const BankEmoji = () => ( + <EmojiWrapper emoji="🏦" labelI18nKey="emoji.bank" /> +) + export const DepositEmoji = () => ( <EmojiWrapper emoji="📥" labelI18nKey="emoji.deposit" /> ) diff --git a/packages/stateless/components/proposal/GovernanceProposal.tsx b/packages/stateless/components/proposal/GovernanceProposal.tsx index 6ca610d825..9b3b429b82 100644 --- a/packages/stateless/components/proposal/GovernanceProposal.tsx +++ b/packages/stateless/components/proposal/GovernanceProposal.tsx @@ -5,6 +5,7 @@ import { TimelapseRounded, TimerRounded, } from '@mui/icons-material' +import clsx from 'clsx' import { ProposalStatus } from 'cosmjs-types/cosmos/gov/v1beta1/gov' import { ComponentType } from 'react' import { useTranslation } from 'react-i18next' @@ -44,6 +45,7 @@ export type GovernanceProposalProps = { TokenAmountDisplay: ComponentType<StatefulTokenAmountDisplayProps> // Needed to display CommunityPoolSpendProposal types. PayEntityDisplay?: ComponentType<StatefulPayEntityDisplayProps> + className?: string } export const GovernanceProposal = ({ @@ -55,6 +57,7 @@ export const GovernanceProposal = ({ endDate, TokenAmountDisplay, PayEntityDisplay, + className, }: GovernanceProposalProps) => { const { t } = useTranslation() const timeAgoFormatter = useTranslatedTimeDeltaFormatter({ words: false }) @@ -81,7 +84,7 @@ export const GovernanceProposal = ({ }, ] as ProposalStatusAndInfoProps['info']) : []), - ...(startDate + ...(startDate && status !== ProposalStatus.PROPOSAL_STATUS_DEPOSIT_PERIOD ? ([ { Icon: TimelapseRounded, @@ -97,15 +100,14 @@ export const GovernanceProposal = ({ ] as ProposalStatusAndInfoProps['info']) : []), // If open for voting, show relative time until end. - ...(endDate - ? status === ProposalStatus.PROPOSAL_STATUS_VOTING_PERIOD || - status === ProposalStatus.PROPOSAL_STATUS_DEPOSIT_PERIOD + ...(endDate && status !== ProposalStatus.PROPOSAL_STATUS_DEPOSIT_PERIOD + ? status === ProposalStatus.PROPOSAL_STATUS_VOTING_PERIOD ? ([ { Icon: HourglassTopRounded, label: t('title.timeLeft'), Value: (props) => ( - <Tooltip title={endDate}> + <Tooltip title={formatDateTimeTz(endDate)}> <p {...props}> <TimeAgo date={endDate} formatter={timeAgoFormatter} /> </p> @@ -125,7 +127,7 @@ export const GovernanceProposal = ({ ] return ( - <div className="flex flex-col gap-6"> + <div className={clsx('flex flex-col gap-6', className)}> <div className="flex flex-row items-center gap-4"> <p className="header-text"> {id ? `${id} ` : ''} diff --git a/packages/types/actions.ts b/packages/types/actions.ts index be3f3275b8..85c8cc882b 100644 --- a/packages/types/actions.ts +++ b/packages/types/actions.ts @@ -43,6 +43,7 @@ export enum ActionKey { ManageStorageItems = 'manageStorageItems', GovernanceVote = 'governanceVote', GovernanceProposal = 'governanceProposal', + GovernanceDeposit = 'governanceDeposit', UpgradeV1ToV2 = 'upgradeV1ToV2', EnableVestingPayments = 'enableVestingPayments', EnableRetroactiveCompensation = 'enableRetroactiveCompensation', From ad6ed886da0ce9fdc0552eafe22da302cf8e8775 Mon Sep 17 00:00:00 2001 From: Noah Saso <noahsaso@gmail.com> Date: Wed, 12 Jul 2023 11:53:41 +0200 Subject: [PATCH 17/20] Moved deposit input to the top. --- .../GovernanceDeposit/Component.tsx | 45 +++++++++---------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/packages/stateful/actions/core/chain_governance/GovernanceDeposit/Component.tsx b/packages/stateful/actions/core/chain_governance/GovernanceDeposit/Component.tsx index 9bcf3a1663..7d33e04a09 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceDeposit/Component.tsx +++ b/packages/stateful/actions/core/chain_governance/GovernanceDeposit/Component.tsx @@ -74,7 +74,6 @@ export const GovernanceDepositComponent: ActionComponent< /> ) : ( <SelectInput - containerClassName="mb-4" error={errors?.proposalId} fieldName={(fieldNamePrefix + 'proposalId') as 'proposalId'} register={register} @@ -94,28 +93,7 @@ export const GovernanceDepositComponent: ActionComponent< </SelectInput> ))} - {proposalSelected ? ( - <GovernanceProposal - PayEntityDisplay={PayEntityDisplay} - TokenAmountDisplay={TokenAmountDisplay} - className="rounded-md border border-border-primary p-4" - content={proposalSelected.decodedContent} - deposit={proposalSelected.totalDeposit} - endDate={proposalSelected.votingEndTime} - id={proposalSelected.proposalId.toString()} - startDate={proposalSelected.votingStartTime} - status={proposalSelected.status} - /> - ) : ( - // If not creating and no proposal selected, something went wrong. - !isCreating && ( - <p className="text-text-interactive-error"> - {t('error.failedToFindGovernanceProposal', { id: proposalId })} - </p> - ) - )} - - <div className="space-y-1"> + <div className="mb-4 space-y-1"> <InputLabel name={t('form.deposit')} /> <TokenInput amountError={errors?.deposit?.[0]?.amount} @@ -145,6 +123,27 @@ export const GovernanceDepositComponent: ActionComponent< watch={watch} /> </div> + + {proposalSelected ? ( + <GovernanceProposal + PayEntityDisplay={PayEntityDisplay} + TokenAmountDisplay={TokenAmountDisplay} + className="rounded-md border border-border-secondary p-4" + content={proposalSelected.decodedContent} + deposit={proposalSelected.totalDeposit} + endDate={proposalSelected.votingEndTime} + id={proposalSelected.proposalId.toString()} + startDate={proposalSelected.votingStartTime} + status={proposalSelected.status} + /> + ) : ( + // If not creating and no proposal selected, something went wrong. + !isCreating && ( + <p className="text-text-interactive-error"> + {t('error.failedToFindGovernanceProposal', { id: proposalId })} + </p> + ) + )} </> ) } From d9951236e354f8c143345d8a0cf8bba128861b87 Mon Sep 17 00:00:00 2001 From: Noah Saso <noahsaso@gmail.com> Date: Wed, 12 Jul 2023 18:47:00 +0200 Subject: [PATCH 18/20] Fixed decimals and translation. --- packages/i18n/locales/en/translation.json | 1 + .../GovernanceProposal/Component.tsx | 2 +- .../GovernanceProposal/index.tsx | 38 ++++++++----------- 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/packages/i18n/locales/en/translation.json b/packages/i18n/locales/en/translation.json index 25dbf14690..2efee3ee9a 100644 --- a/packages/i18n/locales/en/translation.json +++ b/packages/i18n/locales/en/translation.json @@ -484,6 +484,7 @@ "payment": "Payment", "percentOfTotalSupply": "Percent of total token supply", "percentOfTotalSupplyTooltip": "{{weight}} of the total token supply will be split evenly among all of the members in this tier. Want to add members with different voting power? Add another tier.", + "plan": "Plan", "postToDelete": "Post to delete", "postToUpdate": "Post to update", "proposalDepositDescription": "The number of tokens that must be deposited to create a proposal. Setting this may deter spam, but setting it too high may limit broad participation.", diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx b/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx index 7772eaa323..8953873011 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx @@ -306,7 +306,7 @@ export const GovernanceProposalComponent: ActionComponent< {data.type === GovernanceProposalType.SoftwareUpgradeProposal && ( <div className="flex flex-col items-stretch gap-1"> - <InputLabel name={t('form.parameterChanges')} /> + <InputLabel name={t('form.plan')} /> <CodeMirrorInput control={control} error={errors?.upgradePlan} diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx b/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx index e4709ef355..329b00d2a8 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx @@ -19,7 +19,6 @@ import { UseTransformToCosmos, } from '@dao-dao/types/actions' import { - convertMicroDenomToDenomWithDecimals, decodeGovProposalContent, encodeRawProtobufMsg, isDecodedStargateMsg, @@ -141,27 +140,22 @@ export const makeGovernanceProposalAction: ActionMaker< type: GovernanceProposalType.TextProposal, title: '', description: '', - deposit: - deposit && !minDeposits.loading - ? [ - { - denom: deposit.denom, - amount: convertMicroDenomToDenomWithDecimals( - deposit.amount, - minDeposits.data[0].decimals - ), - }, - ] - : [], - spends: - deposit && !minDeposits.loading - ? [ - { - denom: deposit.denom, - amount: 1000, - }, - ] - : [], + deposit: deposit + ? [ + { + denom: deposit.denom, + amount: Number(deposit.amount), + }, + ] + : [], + spends: deposit + ? [ + { + denom: deposit.denom, + amount: 1000, + }, + ] + : [], spendRecipient: address, parameterChanges: defaultParameterChanges, upgradePlan: defaultPlan, From 8fa511029b50a410b98d7139a841dcfb68082a2b Mon Sep 17 00:00:00 2001 From: Noah Saso <noahsaso@gmail.com> Date: Wed, 12 Jul 2023 18:51:05 +0200 Subject: [PATCH 19/20] Fix software upgrade proposal protobuf. --- .../core/chain_governance/GovernanceProposal/index.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx b/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx index 329b00d2a8..8ade0d4254 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx @@ -4,6 +4,7 @@ import { ParameterChangeProposal } from 'cosmjs-types/cosmos/params/v1beta1/para import { SoftwareUpgradeProposal } from 'cosmjs-types/cosmos/upgrade/v1beta1/upgrade' import { CommunityPoolSpendProposal } from 'interchain-rpc/types/codegen/cosmos/distribution/v1beta1/distribution' import { TextProposal } from 'interchain-rpc/types/codegen/cosmos/gov/v1beta1/gov' +import Long from 'long' import { useCallback } from 'react' import { waitForAll } from 'recoil' @@ -176,6 +177,7 @@ export const makeGovernanceProposalAction: ActionMaker< parameterChanges, upgradePlan, }) => { + const plan = JSON.parse(upgradePlan) const content = encodeRawProtobufMsg({ typeUrl: type, value: @@ -199,7 +201,12 @@ export const makeGovernanceProposalAction: ActionMaker< ? ({ title, description, - plan: JSON.parse(upgradePlan), + plan: { + ...plan, + height: !isNaN(Number(plan.height)) + ? Long.fromValue(plan.height) + : -1, + }, } as SoftwareUpgradeProposal) : // Default to text proposal. ({ From 54127bb1f6c180938741016e41c2107c29e2c6a3 Mon Sep 17 00:00:00 2001 From: Noah Saso <noahsaso@gmail.com> Date: Wed, 12 Jul 2023 18:52:29 +0200 Subject: [PATCH 20/20] Undid decimals I guess. --- .../GovernanceProposal/index.tsx | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx b/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx index 8ade0d4254..591261e8aa 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx @@ -20,6 +20,7 @@ import { UseTransformToCosmos, } from '@dao-dao/types/actions' import { + convertMicroDenomToDenomWithDecimals, decodeGovProposalContent, encodeRawProtobufMsg, isDecodedStargateMsg, @@ -141,14 +142,18 @@ export const makeGovernanceProposalAction: ActionMaker< type: GovernanceProposalType.TextProposal, title: '', description: '', - deposit: deposit - ? [ - { - denom: deposit.denom, - amount: Number(deposit.amount), - }, - ] - : [], + deposit: + deposit && !minDeposits.loading + ? [ + { + denom: deposit.denom, + amount: convertMicroDenomToDenomWithDecimals( + deposit.amount, + minDeposits.data[0].decimals + ), + }, + ] + : [], spends: deposit ? [ {