From 1e2362db4fc50b06543128b68f5f6852ff073c38 Mon Sep 17 00:00:00 2001 From: noah Date: Thu, 13 Jul 2023 17:50:04 +0200 Subject: [PATCH] Add governance proposal submit and deposit actions (#1300) --- apps/dapp/package.json | 2 +- apps/sda/package.json | 2 +- packages/i18n/locales/en/translation.json | 21 + packages/state/package.json | 2 +- packages/state/recoil/selectors/chain.ts | 39 +- .../GovernanceDeposit/Component.stories.tsx | 82 ++++ .../GovernanceDeposit/Component.tsx | 149 +++++++ .../GovernanceDeposit/README.md | 26 ++ .../GovernanceDeposit/index.tsx | 221 +++++++++++ .../GovernanceProposal/Component.stories.tsx | 66 ++++ .../GovernanceProposal/Component.tsx | 364 ++++++++++++++++++ .../GovernanceProposal/README.md | 50 +++ .../GovernanceProposal/index.tsx | 312 +++++++++++++++ .../GovernanceVote/Component.stories.tsx | 4 + .../GovernanceVote/Component.tsx | 160 ++------ .../chain_governance/GovernanceVote/index.tsx | 4 + .../actions/core/chain_governance/index.ts | 9 +- .../stateful/components/PayEntityDisplay.tsx | 44 +++ .../components/TokenAmountDisplay.tsx | 51 +++ packages/stateful/components/index.ts | 2 + packages/stateful/package.json | 2 +- .../components/MultipleChoiceOptionViewer.tsx | 12 +- .../ProposalInnerContentDisplay.tsx | 12 +- .../stateless/components/PayEntityDisplay.tsx | 61 +++ .../components/actions/RawActionsRenderer.tsx | 28 +- packages/stateless/components/emoji.tsx | 8 + packages/stateless/components/index.tsx | 1 + .../components/inputs/TokenInput.tsx | 7 +- .../proposal/GovernanceProposal.tsx | 201 ++++++++++ .../stateless/components/proposal/index.tsx | 1 + .../components/token/TokenAmountDisplay.tsx | 48 +-- packages/types/actions.ts | 2 + 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 | 73 +++- packages/utils/package.json | 2 +- yarn.lock | 2 +- 44 files changed, 1959 insertions(+), 221 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 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..2efee3ee9a 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", @@ -236,6 +237,7 @@ "pencil": "Pencil", "people": "People", "pick": "Mining pick", + "raisedHand": "Raised hand", "recycle": "Recycle", "robot": "Robot", "suitAndTie": "Suit and tie", @@ -402,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 to select.", @@ -438,6 +441,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,21 +477,25 @@ "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.", "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.", "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", + "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.", @@ -577,11 +585,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.", @@ -649,6 +665,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", @@ -734,6 +751,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.", @@ -841,6 +859,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", @@ -1057,6 +1076,7 @@ "depositFiat": "Deposit fiat", "depositNfts": "Import NFTs", "depositRefunds": "Deposit refunds", + "depositToGovernanceProposal": "Deposit to Governance Proposal", "depositToken": "Deposit ${{tokenSymbol}}", "deposits": "Deposits", "description": "Description", @@ -1214,6 +1234,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/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 + +const Template: ComponentStory = (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..7d33e04a09 --- /dev/null +++ b/packages/stateful/actions/core/chain_governance/GovernanceDeposit/Component.tsx @@ -0,0 +1,149 @@ +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 + PayEntityDisplay: ComponentType + TokenAmountDisplay: ComponentType +} + +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() + + 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 ? ( + + ) : ( + + {proposals.map((proposal) => ( + + ))} + + ))} + +
+ + + setValue( + (fieldNamePrefix + 'deposit.0.denom') as 'deposit.0.denom', + denomOrAddress + ) + } + readOnly={!isCreating} + register={register} + selectedToken={selectedDepositToken} + setValue={setValue} + tokens={depositTokens} + watch={watch} + /> +
+ + {proposalSelected ? ( + + ) : ( + // If not creating and no proposal selected, something went wrong. + !isCreating && ( +

+ {t('error.failedToFindGovernanceProposal', { id: proposalId })} +

+ ) + )} + + ) +} 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": "", + "deposit": [ + { + "denom": "", + "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 = () => ({ + proposalId: '', + deposit: [], +}) + +const Component: ActionComponent = ( + props +) => { + const { isCreating, fieldNamePrefix } = props + const { chainId } = useActionOptions() + const { watch, setValue, setError, clearErrors } = + useFormContext() + + 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 ( + + ) +} + +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 = ( + msg: Record + ) => + 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/GovernanceProposal/Component.stories.tsx b/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.stories.tsx new file mode 100644 index 0000000000..3ac74076ac --- /dev/null +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.stories.tsx @@ -0,0 +1,66 @@ +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' + +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: 100, + denom: 'ujunox', + }, + ], + spends: [ + { + amount: 1, + denom: 'ujunox', + }, + ], + spendRecipient: 'junoRecipient', + 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, + AddressInput, + }, +} 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..8953873011 --- /dev/null +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx @@ -0,0 +1,364 @@ +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 { + AddressInputProps, + GenericToken, + GenericTokenBalance, + GovernanceProposalType, + LoadingData, + StatefulPayEntityDisplayProps, + StatefulTokenAmountDisplayProps, +} from '@dao-dao/types' +import { ActionComponent } from '@dao-dao/types/actions' +import { + NATIVE_TOKEN, + convertMicroDenomToDenomWithDecimals, + ibcAssets, + validateAddress, + validateJSON, + validateRequired, +} from '@dao-dao/utils' + +export type GovernanceProposalOptions = { + minDeposits: LoadingData + PayEntityDisplay: ComponentType + TokenAmountDisplay: ComponentType + AddressInput: ComponentType> +} + +export type GovernanceProposalData = { + type: GovernanceProposalType + title: string + description: string + deposit: { + amount: number + denom: string + }[] + // GovernanceProposalType.CommunityPoolSpendProposal + spends: { + amount: number + denom: string + }[] + spendRecipient: string + // GovernanceProposalType.ParameterChangeProposal + parameterChanges: string + // GovernanceProposalType.SoftwareUpgradeProposal + upgradePlan: string +} + +export const GovernanceProposalComponent: ActionComponent< + GovernanceProposalOptions, + GovernanceProposalData +> = ({ + fieldNamePrefix, + errors, + isCreating, + options: { minDeposits, PayEntityDisplay, TokenAmountDisplay, AddressInput }, + data, +}) => { + 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: spendFields, + append: appendSpend, + remove: removeSpend, + } = useFieldArray({ + control, + name: (fieldNamePrefix + 'spends') as 'spends', + }) + + 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 && ( + <> +
+ + + +
+ +
+ + +
+
+
+ {spendFields.map(({ id, denom }, index) => { + const selectedToken = availableTokens.find( + ({ denomOrAddress }) => denomOrAddress === denom + ) + if (!selectedToken) { + return null + } + + return ( +
+ + setValue( + (fieldNamePrefix + + `spends.${index}.denom`) as `spends.${number}.denom`, + denomOrAddress + ) + } + register={register} + selectedToken={selectedToken} + setValue={setValue} + tokens={{ loading: false, data: availableTokens }} + watch={watch} + /> + + removeSpend(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')} +

+ )} +
+ )} + + ) : ( + ({ + 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 new file mode 100644 index 0000000000..1656d584ac --- /dev/null +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/README.md @@ -0,0 +1,50 @@ +# 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>", + "deposit": [ + { + "denom": "<DENOM>", + "amount": <AMOUNT> + } + ], + "spends": [ + { + "denom": "<DENOM>", + "amount": <AMOUNT> + }, + ... + ], + "spendRecipient": "<ADDRESS>", + "parameterChanges": "<PARAMETER CHANGES JSON>", + "upgradePlan": "<UPGRADE PLAN JSON>" +} +``` + +`type` is one of: + +- `/cosmos.gov.v1beta1.TextProposal` +- `/cosmos.distribution.v1beta1.CommunityPoolSpendProposal` +- `/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`. 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..591261e8aa --- /dev/null +++ b/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx @@ -0,0 +1,312 @@ +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' +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' + +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 { + convertMicroDenomToDenomWithDecimals, + decodeGovProposalContent, + encodeRawProtobufMsg, + isDecodedStargateMsg, + makeStargateMessage, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { AddressInput } from '../../../../components/AddressInput' +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, + AddressInput, + }} + /> + ) +} + +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 + ) + 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: + deposit && !minDeposits.loading + ? [ + { + denom: deposit.denom, + amount: convertMicroDenomToDenomWithDecimals( + deposit.amount, + minDeposits.data[0].decimals + ), + }, + ] + : [], + spends: deposit + ? [ + { + denom: deposit.denom, + amount: 1000, + }, + ] + : [], + spendRecipient: address, + parameterChanges: defaultParameterChanges, + upgradePlan: defaultPlan, + } + } + + const useTransformToCosmos: UseTransformToCosmos< + GovernanceProposalData + > = () => + useCallback( + ({ + type, + title, + description, + deposit, + spends, + spendRecipient, + parameterChanges, + upgradePlan, + }) => { + const plan = JSON.parse(upgradePlan) + const content = encodeRawProtobufMsg({ + typeUrl: type, + value: + type === GovernanceProposalType.CommunityPoolSpendProposal + ? ({ + title, + description, + amount: spends.map(({ amount, denom }) => ({ + denom, + amount: BigInt(amount).toString(), + })), + recipient: spendRecipient, + } as CommunityPoolSpendProposal) + : type === GovernanceProposalType.ParameterChangeProposal + ? ({ + title, + description, + changes: JSON.parse(parameterChanges), + } as ParameterChangeProposal) + : type === GovernanceProposalType.SoftwareUpgradeProposal + ? ({ + title, + description, + plan: { + ...plan, + height: !isNaN(Number(plan.height)) + ? Long.fromValue(plan.height) + : -1, + }, + } as SoftwareUpgradeProposal) + : // Default to text proposal. + ({ + title, + description, + } as TextProposal), + }) + + return makeStargateMessage({ + stargate: { + typeUrl: SUBMIT_PROPOSAL_TYPE_URL, + value: { + content, + initialDeposit: deposit.map(({ amount, denom }) => ({ + amount: BigInt(amount).toString(), + denom, + })), + 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, + spends: + decodedContent.typeUrl === + GovernanceProposalType.CommunityPoolSpendProposal + ? (decodedContent.value.amount as Coin[]).map( + ({ amount, denom }) => ({ + amount: Number(amount), + denom, + }) + ) + : [], + spendRecipient: + decodedContent.typeUrl === + GovernanceProposalType.CommunityPoolSpendProposal + ? decodedContent.value.recipient + : address, + 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..cbf7db1091 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 && @@ -100,111 +92,25 @@ 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> ))} {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 +268,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..def274a188 100644 --- a/packages/stateful/actions/core/chain_governance/index.ts +++ b/packages/stateful/actions/core/chain_governance/index.ts @@ -1,5 +1,7 @@ import { ActionCategoryKey, ActionCategoryMaker } from '@dao-dao/types' +import { makeGovernanceDepositAction } from './GovernanceDeposit' +import { makeGovernanceProposalAction } from './GovernanceProposal' import { makeGovernanceVoteAction } from './GovernanceVote' import { makeValidatorActionsAction } from './ValidatorActions' @@ -9,5 +11,10 @@ export const makeChainGovernanceActionCategory: ActionCategoryMaker = ({ key: ActionCategoryKey.ChainGovernance, label: t('actionCategory.chainGovernanceLabel'), description: t('actionCategory.chainGovernanceDescription'), - actionMakers: [makeGovernanceVoteAction, makeValidatorActionsAction], + actionMakers: [ + makeGovernanceVoteAction, + makeGovernanceProposalAction, + makeGovernanceDepositAction, + makeValidatorActionsAction, + ], }) diff --git a/packages/stateful/components/PayEntityDisplay.tsx b/packages/stateful/components/PayEntityDisplay.tsx new file mode 100644 index 0000000000..d4df8e267c --- /dev/null +++ b/packages/stateful/components/PayEntityDisplay.tsx @@ -0,0 +1,44 @@ +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.map(({ token }, index) => ({ + token, + balance: coins[index].amount, + }))} + /> + ) +} diff --git a/packages/stateful/components/TokenAmountDisplay.tsx b/packages/stateful/components/TokenAmountDisplay.tsx new file mode 100644 index 0000000000..1993cfb180 --- /dev/null +++ b/packages/stateful/components/TokenAmountDisplay.tsx @@ -0,0 +1,51 @@ +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 + } + iconUrl={ + (!loadingGenericToken.loading && loadingGenericToken.data?.imageUrl) || + undefined + } + 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/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/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/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} /> +} diff --git a/packages/stateless/components/emoji.tsx b/packages/stateless/components/emoji.tsx index 349d97f7d0..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" /> ) @@ -125,6 +129,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..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,7 +212,7 @@ export const TokenInput = < className: 'min-w-[10rem] grow basis-[10rem]', contentContainerClassName: 'justify-between text-icon-primary !gap-4', - disabled: disabled, + disabled: selectDisabled, loading: tokens.loading, size: 'lg', variant: 'ghost_outline', @@ -217,7 +220,7 @@ export const TokenInput = < <> {selectedTokenDisplay} - {!disabled && <ArrowDropDown className="!h-6 !w-6" />} + {!selectDisabled && <ArrowDropDown className="!h-6 !w-6" />} </> ), }, diff --git a/packages/stateless/components/proposal/GovernanceProposal.tsx b/packages/stateless/components/proposal/GovernanceProposal.tsx new file mode 100644 index 0000000000..9b3b429b82 --- /dev/null +++ b/packages/stateless/components/proposal/GovernanceProposal.tsx @@ -0,0 +1,201 @@ +import { + ArrowOutwardRounded, + HourglassTopRounded, + RotateRightOutlined, + 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' +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> + className?: string +} + +export const GovernanceProposal = ({ + id, + status, + content, + deposit, + startDate, + endDate, + TokenAmountDisplay, + PayEntityDisplay, + className, +}: 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 + + 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 && status !== ProposalStatus.PROPOSAL_STATUS_DEPOSIT_PERIOD + ? ([ + { + 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_DEPOSIT_PERIOD + ? status === ProposalStatus.PROPOSAL_STATUS_VOTING_PERIOD + ? ([ + { + Icon: HourglassTopRounded, + label: t('title.timeLeft'), + Value: (props) => ( + <Tooltip title={formatDateTimeTz(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={clsx('flex flex-col gap-6', className)}> + <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> + + {info.length > 0 && ( + <ProposalStatusAndInfo className="max-w-max" info={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('govProposalType.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..85c8cc882b 100644 --- a/packages/types/actions.ts +++ b/packages/types/actions.ts @@ -42,6 +42,8 @@ export enum ActionKey { WithdrawTokenSwap = 'withdrawTokenSwap', ManageStorageItems = 'manageStorageItems', GovernanceVote = 'governanceVote', + GovernanceProposal = 'governanceProposal', + GovernanceDeposit = 'governanceDeposit', 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..82708ae055 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 => @@ -582,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(value, { + typeUrl: {}, + value: {}, + }) && + typeof (value as Any).typeUrl === 'string' && + (value as Any).value instanceof Uint8Array + ) { + decodedValue = decodeRawProtobufMsg(value as Any) + } + } catch {} + + return { + ...acc, + [key]: decodeNestedProtobufs(decodedValue), + } + }, {} as Record<string, any>) 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==