diff --git a/apps/dapp/.env.mainnet b/apps/dapp/.env.mainnet index 1eac362a6..7dc3daa8e 100644 --- a/apps/dapp/.env.mainnet +++ b/apps/dapp/.env.mainnet @@ -29,9 +29,6 @@ NEXT_PUBLIC_RETROACTIVE_COMPENSATION_API_BASE=https://retroactive-compensation.d NEXT_PUBLIC_FAST_AVERAGE_COLOR_API_TEMPLATE=https://fac.withoutdoing.com/URL -# Comma separated list of action keys to disable. -NEXT_PUBLIC_DISABLED_ACTIONS= - # Discord notifications NEXT_PUBLIC_DISCORD_NOTIFIER_CLIENT_ID=1060326264801595402 NEXT_PUBLIC_DISCORD_NOTIFIER_API_BASE=https://discord-notifier.daodao.zone diff --git a/apps/dapp/.env.testnet b/apps/dapp/.env.testnet index 9339522c0..f48dcf6a8 100644 --- a/apps/dapp/.env.testnet +++ b/apps/dapp/.env.testnet @@ -28,9 +28,6 @@ NEXT_PUBLIC_RETROACTIVE_COMPENSATION_API_BASE=https://retroactive-compensation.d NEXT_PUBLIC_FAST_AVERAGE_COLOR_API_TEMPLATE=https://fac.withoutdoing.com/URL -# Comma separated list of action keys to disable. -NEXT_PUBLIC_DISABLED_ACTIONS= - # Discord notifier NEXT_PUBLIC_DISCORD_NOTIFIER_CLIENT_ID=1060326264801595402 NEXT_PUBLIC_DISCORD_NOTIFIER_API_BASE=https://discord-notifier.daodao.zone diff --git a/apps/sda/.env.mainnet b/apps/sda/.env.mainnet index df618585c..634dfc3bb 100644 --- a/apps/sda/.env.mainnet +++ b/apps/sda/.env.mainnet @@ -26,9 +26,6 @@ NEXT_PUBLIC_RETROACTIVE_COMPENSATION_API_BASE=https://retroactive-compensation.d NEXT_PUBLIC_FAST_AVERAGE_COLOR_API_TEMPLATE=https://fac.withoutdoing.com/URL -# Comma separated list of action keys to disable. -NEXT_PUBLIC_DISABLED_ACTIONS= - # Discord notifications NEXT_PUBLIC_DISCORD_NOTIFIER_CLIENT_ID=1060326264801595402 NEXT_PUBLIC_DISCORD_NOTIFIER_API_BASE=https://discord-notifier.daodao.zone diff --git a/apps/sda/.env.testnet b/apps/sda/.env.testnet index 63f6de405..d8e4a8d11 100644 --- a/apps/sda/.env.testnet +++ b/apps/sda/.env.testnet @@ -26,9 +26,6 @@ NEXT_PUBLIC_RETROACTIVE_COMPENSATION_API_BASE=https://retroactive-compensation.d NEXT_PUBLIC_FAST_AVERAGE_COLOR_API_TEMPLATE=https://fac.withoutdoing.com/URL -# Comma separated list of action keys to disable. -NEXT_PUBLIC_DISABLED_ACTIONS= - # Discord notifier NEXT_PUBLIC_DISCORD_NOTIFIER_CLIENT_ID=1060326264801595402 NEXT_PUBLIC_DISCORD_NOTIFIER_API_BASE=https://discord-notifier.daodao.zone diff --git a/packages/config/ts/base.json b/packages/config/ts/base.json index a9db5a8ba..0d1416512 100644 --- a/packages/config/ts/base.json +++ b/packages/config/ts/base.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig", "display": "Default", "compilerOptions": { - "target": "es2020", + "target": "es2022", "composite": false, "declaration": true, "declarationMap": true, diff --git a/packages/config/ts/react-library.json b/packages/config/ts/react-library.json index d5f6969e8..0d21cea8d 100644 --- a/packages/config/ts/react-library.json +++ b/packages/config/ts/react-library.json @@ -6,7 +6,7 @@ "jsx": "react-jsx", "lib": ["dom", "dom.iterable", "esnext"], "module": "esnext", - "target": "es2020", + "target": "es2022", "allowJs": true, "noEmit": true } diff --git a/packages/i18n/locales/bad/translation.json b/packages/i18n/locales/bad/translation.json index c3bb1d222..24668205b 100644 --- a/packages/i18n/locales/bad/translation.json +++ b/packages/i18n/locales/bad/translation.json @@ -475,7 +475,6 @@ "tooFewChoices": "bad bad bad bad bad bad bad bad", "tooManyChoices": "bad bad bad bad bad bad bad bad", "treasuryInsufficient": "bad bad bad bad bad bad bad bad bad", - "treasuryNoTokensCannotStake": "bad bad bad bad bad bad bad bad bad bad bad", "txNotFound": "bad bad bad", "unexpectedError": "bad bad bad bad", "unknownDenom": "bad bad bad", @@ -630,8 +629,8 @@ "nftUploadMetadataInstructions": "bad bad bad bad bad bad bad bad bad bad bad bad bad", "noOne": "bad bad", "oneOneCollection": "bad bad", + "onlyMembersExecuteDescription": "bad bad bad bad bad bad bad bad", "onlyMembersExecuteTitle": "bad bad bad", - "onlyMembersExecuteTooltip": "bad bad bad bad bad bad bad bad", "openDate": "bad bad", "optional": "bad", "options": "bad", @@ -668,8 +667,6 @@ "refundFailedProposalsTitle": "bad bad bad", "refundFailedProposalsTooltip": "bad bad bad bad bad bad bad", "refundPolicyTitle": "bad bad bad bad bad bad bad bad bad", - "requireProposalDepositTitle": "bad bad bad", - "requireProposalDepositTooltip": "bad bad bad bad bad bad bad bad bad bad bad", "revokeAuthorizationOption": "bad bad", "royalties": "bad", "salt": "bad", @@ -964,6 +961,7 @@ "govNftCollection": "bad bad bad", "govTokenAddress": "bad bad", "groupAddress": "bad bad", + "hideIcaDescription": "bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad", "historySinceDate": "bad bad bad", "ibcTransferPathTooltip": "bad bad bad bad bad bad bad bad bad", "ibcTransferPathTooltip_pfm": "bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad", @@ -991,7 +989,6 @@ "logInToViewMembership": "bad bad bad bad bad bad bad bad bad bad", "logo": "bad", "majority": "bad bad", - "manageIcasDescription": "bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad", "manageMembersActionDescription": "bad bad bad bad bad bad bad bad", "managePayrollDescription": "bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad bad", "manageStakingDescription": "bad bad bad bad bad bad bad bad bad bad", @@ -1043,7 +1040,6 @@ "noPostsFound": "bad bad bad", "noPriceData": "bad bad bad", "noProposalActions": "bad bad bad bad bad bad bad", - "noProposalsToVoteOnYet": "bad bad bad bad bad bad", "noPushNotificationSubscriptions": "bad bad bad bad bad bad bad bad", "noSubDaosYet": "bad bad bad", "noSubmission": "bad bad", @@ -1538,6 +1534,7 @@ "governanceToken": "bad bad", "header": "bad", "hidden": "bad", + "hideIca": "bad bad", "highestOffer": "bad bad", "history": "bad", "holdings": "bad", @@ -1557,7 +1554,6 @@ "loggedOut": "bad bad", "loggingInToService": "bad bad bad bad", "manage": "bad", - "manageIcas": "bad bad", "manageMembers": "bad bad", "managePayroll": "bad bad", "manageStaking": "bad bad", diff --git a/packages/i18n/locales/dog/translation.json b/packages/i18n/locales/dog/translation.json index f1027fe80..37851d98a 100644 --- a/packages/i18n/locales/dog/translation.json +++ b/packages/i18n/locales/dog/translation.json @@ -166,7 +166,6 @@ "stakeInsufficient": "da0 haz {{amount}} ${{tokenSymbol}} steaked, which is insufficien", "subDaoAlreadyExists": "subdao alredie existz", "treasuryInsufficientCannotStake": "da trezurie onlee haz {{amount}} ${{tokenSymbol}}, which iz insuffishent", - "treasuryNoTokensCannotStake": "da trezurie haz no ${{tokenSymbol}}, so u cnt steak enny toekebz", "unexpectedError": "an unexpectedd errur occurd" }, "form": { @@ -205,8 +204,8 @@ "migrateDescription": "dis wil <1>migrate da selected contrac 2 a new code id", "migrateMessage": "migr8 messag", "name": "naem", + "onlyMembersExecuteDescription": "if enabeled, onlie memberz mae execuut passd puppozalz", "onlyMembersExecuteTitle": "onlie membaz execute", - "onlyMembersExecuteTooltip": "if enabeled, onlie memberz mae execuut passd puppozalz", "optional": "opshonal", "passingThresholdDescription": "if ur da0 haz no kworum set. dis iz da pupercantage ov da da0z voatbing powar dat mus voat 'yes' 4 a pupozal 2 pase. fer exemple, wif a 50% passthreshol. holf ov teh voatbing powar mus b in faver ov a pupozal 2 pase it. if ur da0 haz a kworum set, teh pasing threshol iz onlie kalkul8d from dose hoo voated. fer exemple, wif a kworum ov 50% n a pasing threshol ov 50%. a pupozal kud pase wif onlie 25% ov teh total voatbing powar hoo voated 'yes'. a majoreetee pasing threshol iz recomended", "passingThresholdTitle": "paseeng threshold", @@ -221,8 +220,6 @@ "refundFailedProposalsTitle": "refund fialed puppozalz", "refundFailedProposalsTooltip": "shud faild puppozalz hav der depositz refunded¿", "refundPolicyTitle": "wunce a puppozal complectz, when shood deppositz bee refunded¿", - "requireProposalDepositTitle": "reqwire puppozal deppozit", - "requireProposalDepositTooltip": "if enabeled, requriez dat tokkenz ar depositid 2 cre8 a puppozal", "selectAnImage": "selec an imaj", "showAdvancedSettings": "show advanst setteengs", "smartContractAddress": "smarrt contrac adres", @@ -330,7 +327,6 @@ "noDaosFollowedYet": "u hav not yet fawloed ennie da0z", "noDisplayName": "no display naem", "noNftsBeingDisplayed": "no nftz ar bein dizplaid", - "noProposalsToVoteOnYet": "no puppozalz 2 voat on yit", "noSubDaosYet": "no subda0z yit", "noVote": "no", "none": "none", diff --git a/packages/i18n/locales/en/translation.json b/packages/i18n/locales/en/translation.json index a67f2e950..6826d7587 100644 --- a/packages/i18n/locales/en/translation.json +++ b/packages/i18n/locales/en/translation.json @@ -1,7 +1,7 @@ { "accountTypeLabel": { + "base": "Core", "ica": "ICA", - "native": "Native", "polytone": "Cross-chain", "valence": "Rebalancer" }, @@ -331,6 +331,7 @@ "curvedDownArrow": "Curved down arrow", "cycle": "Cycle", "deposit": "Deposit", + "dottedLineFace": "Dotted line face", "downArrow": "Down arrow", "family": "Family", "fileFolder": "File folder", @@ -383,6 +384,7 @@ "accountNotFound": "Account not found.", "acknowledgeServiceFee": "You must acknowledge the service fee before you can continue.", "actionFailedToLoad": "The \"{{action}}\" action failed to load: {{error}}.", + "actionNotFound": "Action with key {{key}} not found.", "addressNotAMember": "This address it not a member of the DAO.", "addressNotFoundOnChain": "Address not found on chain.", "alreadySentTokenSwap_dao": "The DAO has already sent its share of this token swap.", @@ -441,6 +443,7 @@ "icaHostUnsupported": "ICA is either unsupported or misconfigured on {{chain}}, so it is unsafe to use.", "insufficientBalance": "Insufficient balance of {{amount}} ${{tokenSymbol}}.", "insufficientFunds": "Insufficient funds.", + "insufficientFundsWarning": "You currently have {{amount}} ${{tokenSymbol}}, which may not be sufficient unless another action transfers funds to the DAO before this one.", "insufficientWalletBalance": "Insufficient wallet balance of {{amount}} ${{tokenSymbol}}.", "invalidAccount": "At least one of the specified accounts is invalid.", "invalidActionKeys": "Invalid action keys found: {{keys}}", @@ -527,7 +530,6 @@ "relayerWalletNeedsFunds": "The relayer wallet needs more funds to pay fees. Press Retry to top up the wallet and try again.", "selectAChainToContinue": "Select a chain to continue.", "simulationFailedInvalidProposalActions": "Simulation failed. Verify your proposal actions are valid.", - "spendActionInsufficientWarning": "You currently have {{amount}} ${{tokenSymbol}}, which may not be sufficient unless another action transfers funds to the DAO before this one.", "stakeInsufficient": "The DAO has {{amount}} ${{tokenSymbol}} staked, which is insufficient.", "stargazeDaoNoCrossChainAccountsForPress_action": "This Stargaze DAO has no cross-chain accounts, and Press does not work on Stargaze. Create a cross-chain account for the DAO before setting up Press.", "stargazeDaoNoCrossChainAccountsForPress_daoCreation": "Press does not currently work on Stargaze. Create the DAO first, and then create a cross-chain account in order to set up Press.", @@ -538,7 +540,6 @@ "tooFewChoices": "The proposal must have at least two choices.", "tooManyChoices": "The proposal cannot have more than {{count}} choices.", "treasuryInsufficient": "The treasury only has {{amount}} ${{tokenSymbol}}, which is insufficient.", - "treasuryNoTokensCannotStake": "The treasury has no ${{tokenSymbol}}, so you can't stake any tokens.", "txNotFound": "Transaction not found.", "unexpectedError": "An unexpected error occurred.", "unexpectedlyMissingChains": "Unexpectedly missing chains: {{chains}}. Please contact support.", @@ -714,8 +715,8 @@ "nobody": "Nobody", "oneOneCollection": "1/1 Collection", "oneOrMoreAccounts": "One or more accounts", + "onlyMembersExecuteDescription": "If enabled, only members may execute passed proposals.", "onlyMembersExecuteTitle": "Only members execute", - "onlyMembersExecuteTooltip": "If enabled, only members may execute passed proposals.", "openDate": "Open date", "optional": "optional", "options": "Options", @@ -761,8 +762,6 @@ "refundFailedProposalsTitle": "Refund failed proposals", "refundFailedProposalsTooltip": "Should failed proposals have their deposit refunded?", "refundPolicyTitle": "Once a proposal completes, when should deposits be refunded?", - "requireProposalDepositTitle": "Require proposal deposit", - "requireProposalDepositTooltip": "If enabled, requires that tokens are deposited to create a proposal.", "revokeAuthorizationOption": "Revoke authorization", "royalties": "Royalties", "salt": "Salt", @@ -1081,6 +1080,7 @@ "estimatedStargazeUsdValueTooltip": "USD value is estimated using price data from Stargaze. This is not fully reflective of realizable spending power due to liquidity limitations.", "estimatedTreasuryUsdValueTooltip": "The USD value of DAO treasuries is estimated by summing the value of all tokens held in the treasury that are listed on CoinGecko, Osmosis, Astroport, Stargaze, and White Whale. This is not fully reflective of realizable spending power due to liquidity limitations.", "estimatedUsdValueTooltip": "USD value is estimated using price data from CoinGecko, Osmosis, White Whale, and Astroport. This is not fully reflective of realizable spending power due to liquidity limitations.", + "executeProposalDescription": "Execute a proposal in another DAO.", "executeSmartContractActionDescription": "Execute a message on a smart contract.", "executorAccountTooltip": "This is the account that will be executing the message on the smart contract. The contract will see this as the `sender`.", "extensionsDescription": "Optionally set up extensions to expand your DAO's capabilities. You can always set these up later.", @@ -1103,6 +1103,7 @@ "govNftCollection": "Governance NFT Collection", "govTokenAddress": "Governance Token", "groupAddress": "CW4 Group", + "hideIcaDescription": "Hide an existing ICA and its assets from displaying in the treasury and being usable in actions. The ICA will not and cannot be destroyed—you will still retain control over it.", "highestUsdValue": "Highest USD value", "historySinceDate": "History since {{date}}", "howCompleteCycle": "How do you want to complete this cycle?", @@ -1119,7 +1120,7 @@ "installKeplrMobileOrScanQrCode": "If you don't have Keplr Mobile installed, <2>click here to install it or scan the QR code at the bottom with another device.", "instantiatePredictableSmartContractActionDescription": "Instantiate a smart contract with a predictable address.", "instantiateSmartContractActionDescription": "Instantiate a smart contract.", - "instantiatorAccountTooltip": "This is the account that will be instantiating the message on the smart contract. The contract will see this as the `sender`.", + "instantiatorAccountTooltip": "This is the account that will be instantiating the smart contract. The contract will see this as the `sender`.", "intakeClosesAt": "Intake closes at {{date}}.", "intakeOpensAt": "Intake opens at {{date}}.", "intakeOpensAtAndClosesAt": "Intake opens at {{openDate}} and closes at {{closeDate}}.", @@ -1137,7 +1138,6 @@ "logo": "Logo", "lowestUsdValue": "Lowest USD value", "majority": "Majority (>50%)", - "manageIcasDescription": "Register or unregister an ICA chain. ICAs must be registered (once created) to be shown in the treasury and used in actions.", "manageMembersActionDescription": "Add, update, or remove members from the DAO.", "managePayrollDescription": "Manage the payroll system used by the DAO. The chosen system appears as a new tab on the DAO's home page.", "manageStakingDescription": "Manage native token staking: claim rewards, delegate, redelegate, and undelegate.", @@ -1159,7 +1159,6 @@ "mergeProfilesExplanation": "Select the profile you want to keep (the rest will be merged into it).", "mergeProfilesTooltip": "Your wallet is attached to multiple profiles. Merge them to avoid confusion.", "migrateFollowingDescription": "Followed DAOs require a migration to a new storage mechanism.", - "migrateMigalooV4TokenFactoryExplanation": "The token factory module on Migaloo has been migrated to a new version that is more actively maintained. Because this DAO created a new token before the migration, the DAO's token factory contract that interacts with the chain module must now be upgraded. This action upgrades the token factory contract to a version that supports the new token factory module.", "migrateSmartContractActionDescription": "Migrate a CosmWasm contract to a new code ID.", "migrateTokenFactoryModuleDescription": "Update the DAO to support the new token factory module.", "minimumOutputRequiredDescription_dao": "Before the proposal is passed and executed, the swap price will fluctuate. If the price drops and no longer satisfies this minimum output required, the swap will not occur.", @@ -1199,7 +1198,6 @@ "noPriceData": "No price data.", "noProposalActions": "This proposal does not perform any actions.", "noProposalsFound": "No proposals found.", - "noProposalsToVoteOnYet": "No proposals to vote on yet.", "noPushNotificationSubscriptions": "You do not have any push notification subscriptions.", "noQuorum": "No quorum", "noSubDaosYet": "No SubDAOs yet.", @@ -1324,7 +1322,6 @@ "redelegate": "Redelegate", "refund": "Refund", "register": "Register", - "registerIcaDescription": "Registering an ICA (once it's created) shows it in the treasury and makes it usable in actions.", "registerSlashVestingExplanation": "<0>When a slash occurs against a validator with whom a vesting contract is currently staking or unstaking tokens, the slash needs to be registered with the vesting contract. For more information, see the Slashing section of the vesting contract's<1>security documentation<2>.", "relayingCrossChainMessages": "Cross-chain messages are waiting to be relayed. Check back in a few minutes to ensure relaying is complete.", "remainingBalanceVesting": "Remaining balance vesting", @@ -1430,7 +1427,6 @@ "unfollowTooltip": "Stop receiving updates about this DAO.", "unknown": "Unknown", "unpaid": "Unpaid", - "unregisterIcaDescription": "Unregistering an ICA removes it from displaying in the treasury and being usable in actions.", "unstake": "Unstake", "unstakingDurationExplanation": "It takes {{duration}} to unstake tokens.", "unstakingDurationNoneExplanation": "Your tokens will unstake immediately.", @@ -1478,7 +1474,7 @@ "vetoActionDaoMemberExplanation_withoutEarlyExecute": "The button below will open the proposal creation page in the vetoer DAO, prefilled with the veto action. Nothing will happen until you submit, pass, and execute that proposal.", "vetoDescription": "Give veto authority to another DAO, allowing them to veto proposals passed by this DAO. Ensure the veto timelock duration is longer than the vetoer's voting period so they have time to pass their veto proposals.", "vetoEarlyExecuteExplanation": "If you do not want to veto this proposal, you may execute it early instead, skipping the rest of the veto timelock period. Otherwise, the proposal will be executable once the veto timelock period expires.", - "vetoOrEarlyExecuteDescription": "Veto a proposal in another DAO that you have veto power over or early-execute if you decide not to veto (if early-execute is enabled).", + "vetoProposalDescription": "Veto a proposal in another DAO that you have veto power over.", "vetoThresholdTooltip": "A proposal must attain this proportion of 'No with Veto' votes to be vetoed.", "vetoableProposalsTooltip": "Proposals in other DAOs that this DAO ({{daoName}}) has the power to veto.", "vetoed": "Vetoed", @@ -1654,6 +1650,7 @@ "choices": "Choices", "chooseAnAction": "Choose an action...", "chooseProfilePicture": "Choose Profile Picture", + "chooseProposal": "Choose proposal", "chooseTokenAmount": "Choose token amount", "claimRewards": "Claim Rewards", "claimTokens": "Claim Tokens", @@ -1736,6 +1733,7 @@ "errored": "Errored", "estUsdValue": "Est. USD value", "established": "Established", + "executeProposal": "Execute Proposal", "executeSmartContract": "Execute Smart Contract", "existingProposal": "Existing proposal", "existingToken": "Existing token", @@ -1761,6 +1759,7 @@ "governanceToken": "Governance Token", "header": "Header", "hidden": "Hidden", + "hideIca": "Hide ICA", "highestOffer": "Highest offer", "history": "History", "holdings": "Holdings", @@ -1782,7 +1781,6 @@ "loggedOut": "Logged out", "loggingInToService": "Logging in to {{service}}...", "manage": "Manage", - "manageIcas": "Manage ICAs", "manageMembers": "Manage Members", "managePayroll": "Manage Payroll", "manageStaking": "Manage Staking", @@ -1893,8 +1891,8 @@ "registerSlash": "Register slash", "relay": "Relay", "relayed": "Relayed", - "removeCw20FromTreasury": "Remove Token Balance from Treasury", - "removeCw721FromTreasury": "Remove NFT Collection from Treasury", + "removeCw20FromTreasury": "Hide Token Balance from Treasury", + "removeCw721FromTreasury": "Hide NFT Collection from Treasury", "removeItem": "Remove Item", "requestedRating": "Requested rating", "resetting": "Resetting...", @@ -2001,7 +1999,7 @@ "vestingCurve": "Vesting Curve", "vestingPayments": "Vesting Payments", "veto": "Veto", - "vetoOrEarlyExecute": "Veto or Early-Execute", + "vetoProposal": "Veto Proposal", "vetoThreshold": "Veto threshold", "vetoTimeLeft": "Veto time left", "vetoable": "Vetoable", diff --git a/packages/i18n/locales/es/translation.json b/packages/i18n/locales/es/translation.json index db823065a..d5bd1fa73 100644 --- a/packages/i18n/locales/es/translation.json +++ b/packages/i18n/locales/es/translation.json @@ -152,8 +152,8 @@ "migrateDescription": "Esto va a <1>migrate el contrato seleccionado a un nuevo código ID.", "migrateMessage": "Mensaje de migración", "name": "Nombre", + "onlyMembersExecuteDescription": "Si activado, solo los miembros pueden ejecutar las propuestas aceptadas.", "onlyMembersExecuteTitle": "Solo miembros lo ejecutan", - "onlyMembersExecuteTooltip": "Si activado, solo los miembros pueden ejecutar las propuestas aceptadas.", "optional": "opcional", "passingThresholdDescription": "Si tu DAO no tiene quorum activado, este es el porcentaje del poder de voto de tu DAO que puede votar 'si' para que pase una propuesta. Por ejemplo, con el límite mínimo al 50%, la mitad del poder de voto tiene que ser a favor de la propuesta para que pase. Si tu DAO tiene el quorum activado, el límite mínimo solo es calculado por los votantes. Por ejemplo, con un quorum de 50% y un límite mínimo de 50%, una propuesta puede pasar con solo 25% del poder de voto total en 'si'. Un límite de mayoría es recomendado.", "passingThresholdTitle": "Límite mínimo", @@ -166,8 +166,6 @@ "refundFailedProposalsDescription": "¿Retornar los activos depositados al proponente de una propuesta que no paso? (Los activos siempre son retornados en propuestas aceptadas). Activar esto alienta el debate antes de crearuna propuesta.", "refundFailedProposalsTitle": "Retornar propuestas fallidas", "refundFailedProposalsTooltip": "¿El depósito de las propuestas fallidas debe ser retornado?", - "requireProposalDepositTitle": "Requerir depósito de propuesta", - "requireProposalDepositTooltip": "Si activado, requiere que tokens sean depositados para crear una propuesta.", "selectAnImage": "Seleccionar una imagen", "smartContractAddress": "Dirección de contrato inteligente.", "tierNameTitle": "Nombre del nivel/grado", diff --git a/packages/i18n/locales/fr/translation.json b/packages/i18n/locales/fr/translation.json index 20e7f920d..6e5ac688c 100644 --- a/packages/i18n/locales/fr/translation.json +++ b/packages/i18n/locales/fr/translation.json @@ -152,8 +152,8 @@ "migrateDescription": "Ceci va <1>migrer le contrat selectionné à un nouvel identifiant.", "migrateMessage": "Message de migration", "name": "Nom", + "onlyMembersExecuteDescription": "Si cette option est activée, seuls les membres peuvent exécuter les propositions transmises.", "onlyMembersExecuteTitle": "Seuls les membres peuvent exécuter", - "onlyMembersExecuteTooltip": "Si cette option est activée, seuls les membres peuvent exécuter les propositions transmises.", "optional": "optionnel", "passingThresholdDescription": "Si votre DAO n'a pas de quorum défini, Ceci est le pourcentage de votes du DAO qui doivent voter oui pour qu'une proposition soit adoptée. Par exemple, avec un seuil de 50%, la moitié des votes doivent être en faveur d'une proposition pour qu'elle soit adoptée. Si votre DAO dispose d'un quorum, le seuil de passage est uniquement calculé à partir de ceux qui ont voté. Par exemple, avec un quorum de 50 % et un seuil de réussite de 50 %, une proposition peut être adoptée avec seulement 25 % du nombre total de voix ayant voté oui. Il est recommandé de fixer un seuil de passage à la majorité.", "passingThresholdTitle": "Seuil de passage", @@ -166,8 +166,6 @@ "refundFailedProposalsDescription": "Le dépôt versé pour une proposition rejetée doit-il être remboursé au proposant ? (Les propositions acceptées se voient toujours restituer leur dépôt). L'activation de cette option, en particulier lorsque les dépôts de propositions sont élevés, peut encourager les membres à délibérer avec d'autres membres avant de créer une proposition.", "refundFailedProposalsTitle": "Rembourser les proposition rejetée", "refundFailedProposalsTooltip": "Les propositions rejetées doivent-elles se voir rembourser leur dépôt ?", - "requireProposalDepositTitle": "Exiger un dépôt de proposition", - "requireProposalDepositTooltip": "Si cette option est activée, il faut que des jetons soient déposés pour créer une proposition.", "selectAnImage": "Selectionner une image", "smartContractAddress": "Adresse du smart contract", "tierNameTitle": "Nom de l'échelon", diff --git a/packages/i18n/locales/it/translation.json b/packages/i18n/locales/it/translation.json index a4f84d3d7..6f5d431c8 100644 --- a/packages/i18n/locales/it/translation.json +++ b/packages/i18n/locales/it/translation.json @@ -135,8 +135,8 @@ "migrateDescription": "Ciò <1>migrerà il contratto selezionato a un nuovo codice ID.", "migrateMessage": "Migra il messaggio", "name": "Nome", + "onlyMembersExecuteDescription": "Vuoi che solo i membri possano eseguire la proposta?", "onlyMembersExecuteTitle": "Solo i membri possono eseguire", - "onlyMembersExecuteTooltip": "Vuoi che solo i membri possano eseguire la proposta?", "optional": "Opzionale", "passingThresholdDescription": "La percentuale dei voti che devono essere 'si' perchè la proposta venga approvata. Per esempio, con una soglia del 50%, la metà del potere di voto deve essere in favore di una proposta per passarla. Una soglia di maggioranza è raccomandata.", "passingThresholdTitle": "Soglia di approvazione", @@ -149,8 +149,6 @@ "refundFailedProposalsDescription": "Se una proposta non viene approvata, verrà rimborsato il deposito? Se attiva, quando una proposta è approvata, il deposito è sempre rimborsato. La disattivazione di questa funzionalità, specialmente quando il deposito è elevato, può incoraggiare i membri a riflettere con altri membri prima di creare una proposta.", "refundFailedProposalsTitle": "Rimborsa le proposte non approvate", "refundFailedProposalsTooltip": "Le proposte fallite avranno i loro depositi rimborsati?", - "requireProposalDepositTitle": "Richiedi un deposito per la proposta", - "requireProposalDepositTooltip": "Se attivato, dei token dovranno essere depositati per creare una proposta.", "selectAnImage": "Seleziona un'immagine", "smartContractAddress": "Indirizzo dello smart contract", "tierNameTitle": "Nome del livello", diff --git a/packages/i18n/locales/ko/translation.json b/packages/i18n/locales/ko/translation.json index c2dc3f8e1..ac9fe0845 100644 --- a/packages/i18n/locales/ko/translation.json +++ b/packages/i18n/locales/ko/translation.json @@ -146,8 +146,8 @@ "migrateDescription": "이 기능은 선택하신 계약을 새로운 코드 ID로 <1>이동 시킵니다.", "migrateMessage": "메세지 이동", "name": "이름", + "onlyMembersExecuteDescription": "본 기능이 활성화될 경우, 회원만이 통과된 제안을 실행할 수 있습니다", "onlyMembersExecuteTitle": "회원에게 실행 권한 부여", - "onlyMembersExecuteTooltip": "본 기능이 활성화될 경우, 회원만이 통과된 제안을 실행할 수 있습니다", "optional": "선택적", "passingThresholdDescription": "정족수가 설정되어 있지 않은 경우에 제안이 통과되기 위해 필요한 ‘찬성’ 투표 비율입니다. 예를 들어, 통과 임계값을 50%로 설정하면 제안을 통과시키기 위해서 투표력의 절반이 제안에 찬성해야 합니다. 정족수가 설정되어 있는 경우, 통과 임계값은 유권자의 투표 결과로부터만 계산됩니다. 예를 들어 정족수 50% 및 통과 임계값이 50%인 경우, 전체 투표력의 25%가 '찬성'에 투표하였을 때 제안이 통과될 수 있습니다. 이 값의 설정을 권장합니다.", "passingThresholdTitle": "통과 임계값", @@ -160,8 +160,6 @@ "refundFailedProposalsDescription": "제안이 통과되지 못할 경우 제안자에게 보증금을 반환할 지에 대한 결정입니다. (통과된 제안은 항상 보증금을 돌려받습니다.) 제안 보증금이 높은 경우, 이 기능을 활성화하면 회원들이 신중히 제안서를 작성하도록 유도할 수 있습니다.", "refundFailedProposalsTitle": "통과되지 못한 제안에 대한 보증금 반환", "refundFailedProposalsTooltip": "통과되지 못한 제안의 보증금을 반환하도록 하겠습니까?", - "requireProposalDepositTitle": "제안의 보증금 필요", - "requireProposalDepositTooltip": "이 기능이 활성화되면 회원은 제안을 생성하기 위해 보증금을 내야합니다.", "selectAnImage": "이미지를 선택하십시오", "smartContractAddress": "스마트 계약 주소", "tierNameTitle": "티어 이름", diff --git a/packages/i18n/locales/pl/translation.json b/packages/i18n/locales/pl/translation.json index 24ebbdbb1..e2185b9a7 100644 --- a/packages/i18n/locales/pl/translation.json +++ b/packages/i18n/locales/pl/translation.json @@ -149,8 +149,8 @@ "migrateDescription": "Tol <1>przeniesie wybrany kontrakt do nowego kodu ID.", "migrateMessage": "Aktualizacja wiadomości", "name": "Nazwa", + "onlyMembersExecuteDescription": "Jeśli aktywowane, tylko członkowie mogą wykoniać zatwierdzone ustawy.", "onlyMembersExecuteTitle": "Tylko członkowie egzekwują", - "onlyMembersExecuteTooltip": "Jeśli aktywowane, tylko członkowie mogą wykoniać zatwierdzone ustawy.", "optional": "opcjonalnie", "passingThresholdDescription": "Jeśli twoja Organizacja nie ma określonego kworum, tylko wynosi procent głosów na ‘’tak’’ wymagany by ustawa została zatwierdzona. Na przykład, z 50% progiem zatwierdzenia ustawy, połowa mocy głosów musi być za ustawą by została ona zatwierdzona. Jeśli natomiast twoja Organizacja ma ma ustawione kworum, próg zatwierdzenia ustawy jest tylko kalkulowany na podstawie oddanych głosów. Na przykład, jeśli kworum jest ustawione na 50% a próg zatwierdzenia ustawy na 50%, ustawa może zostać zatwierdzona w wypadku gdy 25% mocy głosów zagłosowała na ‘’tak’’. Większościowy próg zatwierdzenia ustawy jest rekomendowany.", "passingThresholdTitle": "Próg zatwierdzenia ustawy", @@ -163,8 +163,6 @@ "refundFailedProposalsDescription": "Czy niezatwierdzone ustawy powinny mieć refundowany depozyt głosowania? (Zatwierdzone ustawy zawsze mają depozyt zwrócony). Włączenie tej opcji, zwłaszcza gdy depozyt jest wysoki, może zachęcić członków do dyskusji przed tworzeniem propozycji ustawy.", "refundFailedProposalsTitle": "Refunduj niezatwierdzone ustawy", "refundFailedProposalsTooltip": "Czy niezatwierdzone propozycje powinny mieć refundowany depozyt?", - "requireProposalDepositTitle": "Wymagaj depozytu do głosowania", - "requireProposalDepositTooltip": "Jeśli włączone, wymaga zdeponowania tokenów przed startem głosowania nad ustawą.", "selectAnImage": "Wybierz zdjęcie", "smartContractAddress": "Adres kontraktu", "tierNameTitle": "Tytuł pozycji", diff --git a/packages/i18n/locales/uk/translation.json b/packages/i18n/locales/uk/translation.json index 56d784670..fee342a23 100644 --- a/packages/i18n/locales/uk/translation.json +++ b/packages/i18n/locales/uk/translation.json @@ -129,8 +129,8 @@ "membershipBasedTitle": "Звичайна Спілка Осіб", "message": "Інструкції", "name": "Назва", + "onlyMembersExecuteDescription": "Якщо це обрано, тільки Особи у Спілці можуть впроваджувати різні Дії у Спілці.", "onlyMembersExecuteTitle": "Тільки доєднані Особи можуть виконувати Дії.", - "onlyMembersExecuteTooltip": "Якщо це обрано, тільки Особи у Спілці можуть впроваджувати різні Дії у Спілці.", "optional": "необов'язково", "passingThresholdDescription": "Відсоток тих Присяжних які голосуватимуть Так, аби Рада затвердила запропоновані Дії для Здійснення.", "passingThresholdTitle": "Відсоток для затвердження", @@ -143,8 +143,6 @@ "refundFailedProposalsDescription": "Чи повинен повертатися бюджет скликання Ради пропонуючому, якщо запропоновані Дії відхилено? (Для прийнятих Дій бюджет завжди буде повернено). Ввімкнення цієї опції може вмотивувати учасників обговорювати рішення до створення пропозицій, особливо якщо бюджети на скликання великі.", "refundFailedProposalsTitle": "Повертати бюджет скликання Ради, якщо запропоновані Дії відхилено", "refundFailedProposalsTooltip": "Повертати бюджет скликання Ради, якщо запропоновані Дії відхилено?", - "requireProposalDepositTitle": "Вимагати бюджет скликання Ради", - "requireProposalDepositTooltip": "Якщо це обрано, то буде необхідно надати додаткові Акції у бюджет скликання Ради", "selectAnImage": "Обрати Зображення", "smartContractAddress": "Адреса Розумного Контракту", "tierNameTitle": "Назва Куреня", diff --git a/packages/i18n/locales/zh-tw/translation.json b/packages/i18n/locales/zh-tw/translation.json index 7fdb44ea2..1e184eec9 100644 --- a/packages/i18n/locales/zh-tw/translation.json +++ b/packages/i18n/locales/zh-tw/translation.json @@ -151,8 +151,8 @@ "migrateDescription": "這將會<1>遷移已選的合約到新的Code ID", "migrateMessage": "遷移訊息", "name": "名稱", + "onlyMembersExecuteDescription": "如啟用,只有成員可以執行通過的議案。", "onlyMembersExecuteTitle": "只有成員能執行", - "onlyMembersExecuteTooltip": "如啟用,只有成員可以執行通過的議案。", "optional": "可選的", "passingThresholdDescription": "如果你的 DAO 沒有設定 quorum(最少參與投票權),這將會是議案通過所需的 DAO 投票權百分比,例如當此數值被設為50%,一個議案需有一半投票權投贊成以被通過。當 DAO 有設定 quorum,通過門檻便會只以已投票的投票權計算,例如當 quorum 被設為50%及議案通過門檻被設為50%,一個議案便變為需要DAO 25% (50% * 50%)的總投票權投贊成以被通過。建議使用<過半數>。", "passingThresholdTitle": "議案通過門檻", @@ -165,8 +165,6 @@ "refundFailedProposalsDescription": "失敗的議案保證金應該退還給議案發起人?(所有被通過的議案皆會退還保證金)。尤其當議案保證金設得較高,啟用此設定會鼓勵成員們提交新議案。", "refundFailedProposalsTitle": "退還失敗議案保證金", "refundFailedProposalsTooltip": "退還失敗議案保證金?", - "requireProposalDepositTitle": "要求議案保證金", - "requireProposalDepositTooltip": "如啟用,提交議案時必須支付代幣作為議案保證金。", "selectAnImage": "選擇圖片", "smartContractAddress": "智能合約地址", "tierNameTitle": "級別名稱", diff --git a/packages/i18n/locales/zh/translation.json b/packages/i18n/locales/zh/translation.json index 304d6bd2d..4e0875eb5 100644 --- a/packages/i18n/locales/zh/translation.json +++ b/packages/i18n/locales/zh/translation.json @@ -129,8 +129,8 @@ "membershipBasedTitle": "基于成员资格", "message": "信息", "name": "名称", + "onlyMembersExecuteDescription": "如果启动,只有成员可以执行通过的提案。", "onlyMembersExecuteTitle": "只有成员能执行", - "onlyMembersExecuteTooltip": "如果启动,只有成员可以执行通过的提案。", "optional": "可选的", "passingThresholdDescription": "如果你的DAO没有设置法定人数,这是提案通过所需投赞成票占总投票权重的百分比。例如,在50%的通过阈值下,必须有至少一半的投票权赞成一项提案才能通过。如果你的DAO设置了法定人数,那么通过阈值只从投票者中计算。例如,在法定人数为50%,通过阈值为50%的情况下,只有25%的总投票权投了yes,就可以通过一项提案。建议设置一个较高的通过阈值,即大多数人赞成才能通过。", "passingThresholdTitle": "通过阈值", @@ -143,8 +143,6 @@ "refundFailedProposalsDescription": "失败提案的提案押金是否应该退给提议人? (所有通过的提案都会退还押金)。打开这个功能,特别是当提案押金偏高时,可能会鼓励成员在创建提案前与其他成员进行商议。", "refundFailedProposalsTitle": "退款失败提案的提案押金", "refundFailedProposalsTooltip": "失败提案的提案押金是否应该退给提议人?", - "requireProposalDepositTitle": "需要提案押金", - "requireProposalDepositTooltip": "如果启动,需求支付代币充当提案押金才能创建提案。", "selectAnImage": "选择一张图片", "smartContractAddress": "智能合约地址", "tierNameTitle": "级别名", diff --git a/packages/state/contracts/Cw20Stake.ts b/packages/state/contracts/Cw20Stake.ts index 889209048..628d21425 100644 --- a/packages/state/contracts/Cw20Stake.ts +++ b/packages/state/contracts/Cw20Stake.ts @@ -1,3 +1,9 @@ +/** + * This file was automatically generated by @cosmwasm/ts-codegen@1.10.0. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run the @cosmwasm/ts-codegen generate command to regenerate this file. + */ + import { Coin, StdFee } from '@cosmjs/amino' import { CosmWasmClient, @@ -5,16 +11,20 @@ import { SigningCosmWasmClient, } from '@cosmjs/cosmwasm-stargate' -import { Binary, Duration, Uint128 } from '@dao-dao/types/contracts/common' import { + Action, + Binary, ClaimsResponse, - GetConfigResponse, + Config, + Duration, GetHooksResponse, ListStakersResponse, + OwnershipForAddr, StakedBalanceAtHeightResponse, StakedValueResponse, TotalStakedAtHeightResponse, TotalValueResponse, + Uint128, } from '@dao-dao/types/contracts/Cw20Stake' import { CHAIN_GAS_MULTIPLIER } from '@dao-dao/utils' @@ -38,7 +48,7 @@ export interface Cw20StakeReadOnlyInterface { address: string }) => Promise totalValue: () => Promise - getConfig: () => Promise + getConfig: () => Promise claims: ({ address }: { address: string }) => Promise getHooks: () => Promise listStakers: ({ @@ -48,11 +58,11 @@ export interface Cw20StakeReadOnlyInterface { limit?: number startAfter?: string }) => Promise + ownership: () => Promise } export class Cw20StakeQueryClient implements Cw20StakeReadOnlyInterface { client: CosmWasmClient contractAddress: string - constructor(client: CosmWasmClient, contractAddress: string) { this.client = client this.contractAddress = contractAddress @@ -64,8 +74,8 @@ export class Cw20StakeQueryClient implements Cw20StakeReadOnlyInterface { this.claims = this.claims.bind(this) this.getHooks = this.getHooks.bind(this) this.listStakers = this.listStakers.bind(this) + this.ownership = this.ownership.bind(this) } - stakedBalanceAtHeight = async ({ address, height, @@ -107,7 +117,7 @@ export class Cw20StakeQueryClient implements Cw20StakeReadOnlyInterface { total_value: {}, }) } - getConfig = async (): Promise => { + getConfig = async (): Promise => { return this.client.queryContractSmart(this.contractAddress, { get_config: {}, }) @@ -142,6 +152,11 @@ export class Cw20StakeQueryClient implements Cw20StakeReadOnlyInterface { }, }) } + ownership = async (): Promise => { + return this.client.queryContractSmart(this.contractAddress, { + ownership: {}, + }) + } } export interface Cw20StakeInterface extends Cw20StakeReadOnlyInterface { contractAddress: string @@ -158,7 +173,7 @@ export interface Cw20StakeInterface extends Cw20StakeReadOnlyInterface { }, fee?: number | StdFee | 'auto', memo?: string, - funds?: Coin[] + _funds?: Coin[] ) => Promise unstake: ( { @@ -168,26 +183,22 @@ export interface Cw20StakeInterface extends Cw20StakeReadOnlyInterface { }, fee?: number | StdFee | 'auto', memo?: string, - funds?: Coin[] + _funds?: Coin[] ) => Promise claim: ( fee?: number | StdFee | 'auto', memo?: string, - funds?: Coin[] + _funds?: Coin[] ) => Promise updateConfig: ( { duration, - manager, - owner, }: { duration?: Duration - manager?: string - owner?: string }, fee?: number | StdFee | 'auto', memo?: string, - funds?: Coin[] + _funds?: Coin[] ) => Promise addHook: ( { @@ -197,7 +208,7 @@ export interface Cw20StakeInterface extends Cw20StakeReadOnlyInterface { }, fee?: number | StdFee | 'auto', memo?: string, - funds?: Coin[] + _funds?: Coin[] ) => Promise removeHook: ( { @@ -207,7 +218,13 @@ export interface Cw20StakeInterface extends Cw20StakeReadOnlyInterface { }, fee?: number | StdFee | 'auto', memo?: string, - funds?: Coin[] + _funds?: Coin[] + ) => Promise + updateOwnership: ( + action: Action, + fee?: number | StdFee | 'auto', + memo?: string, + _funds?: Coin[] ) => Promise } export class Cw20StakeClient @@ -217,7 +234,6 @@ export class Cw20StakeClient client: SigningCosmWasmClient sender: string contractAddress: string - constructor( client: SigningCosmWasmClient, sender: string, @@ -233,8 +249,8 @@ export class Cw20StakeClient this.updateConfig = this.updateConfig.bind(this) this.addHook = this.addHook.bind(this) this.removeHook = this.removeHook.bind(this) + this.updateOwnership = this.updateOwnership.bind(this) } - receive = async ( { amount, @@ -247,7 +263,7 @@ export class Cw20StakeClient }, fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, memo?: string, - funds?: Coin[] + _funds?: Coin[] ): Promise => { return await this.client.execute( this.sender, @@ -261,7 +277,7 @@ export class Cw20StakeClient }, fee, memo, - funds + _funds ) } unstake = async ( @@ -272,7 +288,7 @@ export class Cw20StakeClient }, fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, memo?: string, - funds?: Coin[] + _funds?: Coin[] ): Promise => { return await this.client.execute( this.sender, @@ -284,13 +300,13 @@ export class Cw20StakeClient }, fee, memo, - funds + _funds ) } claim = async ( fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, memo?: string, - funds?: Coin[] + _funds?: Coin[] ): Promise => { return await this.client.execute( this.sender, @@ -300,22 +316,18 @@ export class Cw20StakeClient }, fee, memo, - funds + _funds ) } updateConfig = async ( { duration, - manager, - owner, }: { duration?: Duration - manager?: string - owner?: string }, fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, memo?: string, - funds?: Coin[] + _funds?: Coin[] ): Promise => { return await this.client.execute( this.sender, @@ -323,13 +335,11 @@ export class Cw20StakeClient { update_config: { duration, - manager, - owner, }, }, fee, memo, - funds + _funds ) } addHook = async ( @@ -340,7 +350,7 @@ export class Cw20StakeClient }, fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, memo?: string, - funds?: Coin[] + _funds?: Coin[] ): Promise => { return await this.client.execute( this.sender, @@ -352,7 +362,7 @@ export class Cw20StakeClient }, fee, memo, - funds + _funds ) } removeHook = async ( @@ -363,7 +373,7 @@ export class Cw20StakeClient }, fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, memo?: string, - funds?: Coin[] + _funds?: Coin[] ): Promise => { return await this.client.execute( this.sender, @@ -375,7 +385,24 @@ export class Cw20StakeClient }, fee, memo, - funds + _funds + ) + } + updateOwnership = async ( + action: Action, + fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, + memo?: string, + _funds?: Coin[] + ): Promise => { + return await this.client.execute( + this.sender, + this.contractAddress, + { + update_ownership: action, + }, + fee, + memo, + _funds ) } } diff --git a/packages/state/contracts/NeutronCwdSubdaoTimelockSingle.ts b/packages/state/contracts/NeutronCwdSubdaoTimelockSingle.ts index 8276d848d..b8ef1257a 100644 --- a/packages/state/contracts/NeutronCwdSubdaoTimelockSingle.ts +++ b/packages/state/contracts/NeutronCwdSubdaoTimelockSingle.ts @@ -1,5 +1,5 @@ /** - * This file was automatically generated by @cosmwasm/ts-codegen@0.35.7. + * This file was automatically generated by @cosmwasm/ts-codegen@1.10.0. * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, * and run the @cosmwasm/ts-codegen generate command to regenerate this file. */ @@ -19,6 +19,7 @@ import { ProposalListResponse, SingleChoiceProposal, } from '@dao-dao/types/contracts/NeutronCwdSubdaoTimelockSingle' +import { CHAIN_GAS_MULTIPLIER } from '@dao-dao/utils' export interface NeutronCwdSubdaoTimelockSingleReadOnlyInterface { contractAddress: string @@ -46,7 +47,6 @@ export class NeutronCwdSubdaoTimelockSingleQueryClient { client: CosmWasmClient contractAddress: string - constructor(client: CosmWasmClient, contractAddress: string) { this.client = client this.contractAddress = contractAddress @@ -55,7 +55,6 @@ export class NeutronCwdSubdaoTimelockSingleQueryClient this.listProposals = this.listProposals.bind(this) this.proposalExecutionError = this.proposalExecutionError.bind(this) } - config = async (): Promise => { return this.client.queryContractSmart(this.contractAddress, { config: {}, @@ -154,7 +153,6 @@ export class NeutronCwdSubdaoTimelockSingleClient client: SigningCosmWasmClient sender: string contractAddress: string - constructor( client: SigningCosmWasmClient, sender: string, @@ -169,7 +167,6 @@ export class NeutronCwdSubdaoTimelockSingleClient this.overruleProposal = this.overruleProposal.bind(this) this.updateConfig = this.updateConfig.bind(this) } - timelockProposal = async ( { msgs, @@ -178,7 +175,7 @@ export class NeutronCwdSubdaoTimelockSingleClient msgs: CosmosMsgForNeutronMsg[] proposalId: number }, - fee: number | StdFee | 'auto' = 'auto', + fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, memo?: string, _funds?: Coin[] ): Promise => { @@ -202,7 +199,7 @@ export class NeutronCwdSubdaoTimelockSingleClient }: { proposalId: number }, - fee: number | StdFee | 'auto' = 'auto', + fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, memo?: string, _funds?: Coin[] ): Promise => { @@ -225,7 +222,7 @@ export class NeutronCwdSubdaoTimelockSingleClient }: { proposalId: number }, - fee: number | StdFee | 'auto' = 'auto', + fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, memo?: string, _funds?: Coin[] ): Promise => { @@ -250,7 +247,7 @@ export class NeutronCwdSubdaoTimelockSingleClient overrulePrePropose?: string owner?: string }, - fee: number | StdFee | 'auto' = 'auto', + fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, memo?: string, _funds?: Coin[] ): Promise => { diff --git a/packages/state/contracts/OraichainCw20Staking.ts b/packages/state/contracts/OraichainCw20Staking.ts index a39b899be..4a9faa7f1 100644 --- a/packages/state/contracts/OraichainCw20Staking.ts +++ b/packages/state/contracts/OraichainCw20Staking.ts @@ -1,5 +1,5 @@ /** - * This file was automatically generated by @cosmwasm/ts-codegen@0.35.7. + * This file was automatically generated by @cosmwasm/ts-codegen@1.10.0. * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, * and run the @cosmwasm/ts-codegen generate command to regenerate this file. */ @@ -27,6 +27,7 @@ import { TotalStakedAtHeightResponse, Uint128, } from '@dao-dao/types/contracts/OraichainCw20Staking' +import { CHAIN_GAS_MULTIPLIER } from '@dao-dao/utils' export interface OraichainCw20StakingReadOnlyInterface { contractAddress: string @@ -95,7 +96,6 @@ export class OraichainCw20StakingQueryClient { client: CosmWasmClient contractAddress: string - constructor(client: CosmWasmClient, contractAddress: string) { this.client = client this.contractAddress = contractAddress @@ -109,7 +109,6 @@ export class OraichainCw20StakingQueryClient this.stakedBalanceAtHeight = this.stakedBalanceAtHeight.bind(this) this.totalStakedAtHeight = this.totalStakedAtHeight.bind(this) } - config = async (): Promise => { return this.client.queryContractSmart(this.contractAddress, { config: {}, @@ -337,7 +336,6 @@ export class OraichainCw20StakingClient client: SigningCosmWasmClient sender: string contractAddress: string - constructor( client: SigningCosmWasmClient, sender: string, @@ -356,7 +354,6 @@ export class OraichainCw20StakingClient this.withdraw = this.withdraw.bind(this) this.withdrawOthers = this.withdrawOthers.bind(this) } - receive = async ( { amount, @@ -367,7 +364,7 @@ export class OraichainCw20StakingClient msg: Binary sender: string }, - fee: number | StdFee | 'auto' = 'auto', + fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, memo?: string, _funds?: Coin[] ): Promise => { @@ -394,7 +391,7 @@ export class OraichainCw20StakingClient owner?: Addr rewarder?: Addr }, - fee: number | StdFee | 'auto' = 'auto', + fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, memo?: string, _funds?: Coin[] ): Promise => { @@ -420,7 +417,7 @@ export class OraichainCw20StakingClient stakingToken: Addr unbondingPeriod?: number }, - fee: number | StdFee | 'auto' = 'auto', + fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, memo?: string, _funds?: Coin[] ): Promise => { @@ -446,7 +443,7 @@ export class OraichainCw20StakingClient assets: Asset[] stakingToken: Addr }, - fee: number | StdFee | 'auto' = 'auto', + fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, memo?: string, _funds?: Coin[] ): Promise => { @@ -470,7 +467,7 @@ export class OraichainCw20StakingClient }: { rewards: RewardMsg[] }, - fee: number | StdFee | 'auto' = 'auto', + fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, memo?: string, _funds?: Coin[] ): Promise => { @@ -495,7 +492,7 @@ export class OraichainCw20StakingClient amount: Uint128 stakingToken: Addr }, - fee: number | StdFee | 'auto' = 'auto', + fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, memo?: string, _funds?: Coin[] ): Promise => { @@ -519,7 +516,7 @@ export class OraichainCw20StakingClient }: { stakingToken?: Addr }, - fee: number | StdFee | 'auto' = 'auto', + fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, memo?: string, _funds?: Coin[] ): Promise => { @@ -544,7 +541,7 @@ export class OraichainCw20StakingClient stakerAddrs: Addr[] stakingToken?: Addr }, - fee: number | StdFee | 'auto' = 'auto', + fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, memo?: string, _funds?: Coin[] ): Promise => { diff --git a/packages/state/contracts/ValenceAccount.ts b/packages/state/contracts/ValenceAccount.ts new file mode 100644 index 000000000..8643e2440 --- /dev/null +++ b/packages/state/contracts/ValenceAccount.ts @@ -0,0 +1,444 @@ +/** + * This file was automatically generated by @cosmwasm/ts-codegen@1.10.0. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run the @cosmwasm/ts-codegen generate command to regenerate this file. + */ + +import { StdFee } from '@cosmjs/amino' +import { + CosmWasmClient, + ExecuteResult, + SigningCosmWasmClient, +} from '@cosmjs/cosmwasm-stargate' + +import { Addr } from '@dao-dao/types' +import { + Binary, + Coin, + CosmosMsgForEmpty, + Expiration, + ValenceServices, +} from '@dao-dao/types/contracts/ValenceAccount' +import { CHAIN_GAS_MULTIPLIER } from '@dao-dao/utils' + +export interface ValenceAccountReadOnlyInterface { + contractAddress: string + getAdmin: () => Promise +} +export class ValenceAccountQueryClient + implements ValenceAccountReadOnlyInterface +{ + client: CosmWasmClient + contractAddress: string + constructor(client: CosmWasmClient, contractAddress: string) { + this.client = client + this.contractAddress = contractAddress + this.getAdmin = this.getAdmin.bind(this) + } + getAdmin = async (): Promise => { + return this.client.queryContractSmart(this.contractAddress, 'get_admin') + } +} +export interface ValenceAccountInterface + extends ValenceAccountReadOnlyInterface { + contractAddress: string + sender: string + registerToService: ( + { + data, + serviceName, + }: { + data?: Binary + serviceName: ValenceServices + }, + fee?: number | StdFee | 'auto', + memo?: string, + _funds?: Coin[] + ) => Promise + deregisterFromService: ( + { + serviceName, + }: { + serviceName: ValenceServices + }, + fee?: number | StdFee | 'auto', + memo?: string, + _funds?: Coin[] + ) => Promise + updateService: ( + { + data, + serviceName, + }: { + data: Binary + serviceName: ValenceServices + }, + fee?: number | StdFee | 'auto', + memo?: string, + _funds?: Coin[] + ) => Promise + pauseService: ( + { + reason, + serviceName, + }: { + reason?: string + serviceName: ValenceServices + }, + fee?: number | StdFee | 'auto', + memo?: string, + _funds?: Coin[] + ) => Promise + resumeService: ( + { + serviceName, + }: { + serviceName: ValenceServices + }, + fee?: number | StdFee | 'auto', + memo?: string, + _funds?: Coin[] + ) => Promise + sendFundsByService: ( + { + atomic, + msgs, + }: { + atomic: boolean + msgs: CosmosMsgForEmpty[] + }, + fee?: number | StdFee | 'auto', + memo?: string, + _funds?: Coin[] + ) => Promise + executeByService: ( + { + atomic, + msgs, + }: { + atomic: boolean + msgs: CosmosMsgForEmpty[] + }, + fee?: number | StdFee | 'auto', + memo?: string, + _funds?: Coin[] + ) => Promise + executeByAdmin: ( + { + msgs, + }: { + msgs: CosmosMsgForEmpty[] + }, + fee?: number | StdFee | 'auto', + memo?: string, + _funds?: Coin[] + ) => Promise + startAdminChange: ( + { + addr, + expiration, + }: { + addr: string + expiration: Expiration + }, + fee?: number | StdFee | 'auto', + memo?: string, + _funds?: Coin[] + ) => Promise + cancelAdminChange: ( + fee?: number | StdFee | 'auto', + memo?: string, + _funds?: Coin[] + ) => Promise + approveAdminChange: ( + fee?: number | StdFee | 'auto', + memo?: string, + _funds?: Coin[] + ) => Promise +} +export class ValenceAccountClient + extends ValenceAccountQueryClient + implements ValenceAccountInterface +{ + client: SigningCosmWasmClient + sender: string + contractAddress: string + constructor( + client: SigningCosmWasmClient, + sender: string, + contractAddress: string + ) { + super(client, contractAddress) + this.client = client + this.sender = sender + this.contractAddress = contractAddress + this.registerToService = this.registerToService.bind(this) + this.deregisterFromService = this.deregisterFromService.bind(this) + this.updateService = this.updateService.bind(this) + this.pauseService = this.pauseService.bind(this) + this.resumeService = this.resumeService.bind(this) + this.sendFundsByService = this.sendFundsByService.bind(this) + this.executeByService = this.executeByService.bind(this) + this.executeByAdmin = this.executeByAdmin.bind(this) + this.startAdminChange = this.startAdminChange.bind(this) + this.cancelAdminChange = this.cancelAdminChange.bind(this) + this.approveAdminChange = this.approveAdminChange.bind(this) + } + registerToService = async ( + { + data, + serviceName, + }: { + data?: Binary + serviceName: ValenceServices + }, + fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, + memo?: string, + _funds?: Coin[] + ): Promise => { + return await this.client.execute( + this.sender, + this.contractAddress, + { + register_to_service: { + data, + service_name: serviceName, + }, + }, + fee, + memo, + _funds + ) + } + deregisterFromService = async ( + { + serviceName, + }: { + serviceName: ValenceServices + }, + fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, + memo?: string, + _funds?: Coin[] + ): Promise => { + return await this.client.execute( + this.sender, + this.contractAddress, + { + deregister_from_service: { + service_name: serviceName, + }, + }, + fee, + memo, + _funds + ) + } + updateService = async ( + { + data, + serviceName, + }: { + data: Binary + serviceName: ValenceServices + }, + fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, + memo?: string, + _funds?: Coin[] + ): Promise => { + return await this.client.execute( + this.sender, + this.contractAddress, + { + update_service: { + data, + service_name: serviceName, + }, + }, + fee, + memo, + _funds + ) + } + pauseService = async ( + { + reason, + serviceName, + }: { + reason?: string + serviceName: ValenceServices + }, + fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, + memo?: string, + _funds?: Coin[] + ): Promise => { + return await this.client.execute( + this.sender, + this.contractAddress, + { + pause_service: { + reason, + service_name: serviceName, + }, + }, + fee, + memo, + _funds + ) + } + resumeService = async ( + { + serviceName, + }: { + serviceName: ValenceServices + }, + fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, + memo?: string, + _funds?: Coin[] + ): Promise => { + return await this.client.execute( + this.sender, + this.contractAddress, + { + resume_service: { + service_name: serviceName, + }, + }, + fee, + memo, + _funds + ) + } + sendFundsByService = async ( + { + atomic, + msgs, + }: { + atomic: boolean + msgs: CosmosMsgForEmpty[] + }, + fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, + memo?: string, + _funds?: Coin[] + ): Promise => { + return await this.client.execute( + this.sender, + this.contractAddress, + { + send_funds_by_service: { + atomic, + msgs, + }, + }, + fee, + memo, + _funds + ) + } + executeByService = async ( + { + atomic, + msgs, + }: { + atomic: boolean + msgs: CosmosMsgForEmpty[] + }, + fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, + memo?: string, + _funds?: Coin[] + ): Promise => { + return await this.client.execute( + this.sender, + this.contractAddress, + { + execute_by_service: { + atomic, + msgs, + }, + }, + fee, + memo, + _funds + ) + } + executeByAdmin = async ( + { + msgs, + }: { + msgs: CosmosMsgForEmpty[] + }, + fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, + memo?: string, + _funds?: Coin[] + ): Promise => { + return await this.client.execute( + this.sender, + this.contractAddress, + { + execute_by_admin: { + msgs, + }, + }, + fee, + memo, + _funds + ) + } + startAdminChange = async ( + { + addr, + expiration, + }: { + addr: string + expiration: Expiration + }, + fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, + memo?: string, + _funds?: Coin[] + ): Promise => { + return await this.client.execute( + this.sender, + this.contractAddress, + { + start_admin_change: { + addr, + expiration, + }, + }, + fee, + memo, + _funds + ) + } + cancelAdminChange = async ( + fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, + memo?: string, + _funds?: Coin[] + ): Promise => { + return await this.client.execute( + this.sender, + this.contractAddress, + { + cancel_admin_change: {}, + }, + fee, + memo, + _funds + ) + } + approveAdminChange = async ( + fee: number | StdFee | 'auto' = CHAIN_GAS_MULTIPLIER, + memo?: string, + _funds?: Coin[] + ): Promise => { + return await this.client.execute( + this.sender, + this.contractAddress, + { + approve_admin_change: {}, + }, + fee, + memo, + _funds + ) + } +} diff --git a/packages/state/contracts/index.ts b/packages/state/contracts/index.ts index 653c59791..7cfbcf0f5 100644 --- a/packages/state/contracts/index.ts +++ b/packages/state/contracts/index.ts @@ -144,3 +144,7 @@ export { DaoVotingSgCommunityNftClient, DaoVotingSgCommunityNftQueryClient, } from './DaoVotingSgCommunityNft' +export { + ValenceAccountClient, + ValenceAccountQueryClient, +} from './ValenceAccount' diff --git a/packages/state/query/queries/account.ts b/packages/state/query/queries/account.ts index 2aae1211a..7fbf2eb42 100644 --- a/packages/state/query/queries/account.ts +++ b/packages/state/query/queries/account.ts @@ -5,6 +5,7 @@ import { Account, AccountType, CryptographicMultisigAccount, + Cw1WhitelistAccount, Cw3MultisigAccount, GenericToken, MultisigAccount, @@ -31,7 +32,12 @@ import { import { chainQueries } from './chain' import { contractQueries } from './contract' -import { cw3FlexMultisigQueries, valenceRebalancerQueries } from './contracts' +import { + cw1WhitelistExtraQueries, + cw3FlexMultisigQueries, + valenceAccountQueries, + valenceRebalancerQueries, +} from './contracts' import { daoDaoCoreQueries } from './contracts/DaoDaoCore' import { indexerQueries } from './indexer' import { polytoneQueries } from './polytone' @@ -91,7 +97,7 @@ export const fetchAccountList = async ( : { chainId, address, - type: isPolytoneProxy ? AccountType.Polytone : AccountType.Native, + type: isPolytoneProxy ? AccountType.Polytone : AccountType.Base, } const [polytoneProxies, registeredIcas] = await Promise.all([ @@ -128,9 +134,9 @@ export const fetchAccountList = async ( ), ] - // If main account is native, load ICA accounts. + // If main account is base, load ICA accounts. const icaChains = - mainAccount.type === AccountType.Native + mainAccount.type === AccountType.Base ? [ ...(registeredIcas || []).map(([key]) => key), ...(includeIcaChains || []), @@ -431,20 +437,32 @@ export const fetchValenceAccount = async ( const rebalancerAddress = getSupportedChainConfig(chainId)?.valence?.rebalancer - const rebalancerConfig = rebalancerAddress - ? await queryClient - .fetchQuery( - valenceRebalancerQueries.getConfig({ - chainId, - contractAddress: rebalancerAddress, - args: { - addr: address, - }, - }) - ) - // This will error when no rebalancer is configured. - .catch(() => null) - : null + const [admin, rebalancerConfig] = await Promise.all([ + queryClient + .fetchQuery( + valenceAccountQueries.getAdmin({ + chainId, + contractAddress: address, + }) + ) + // backwards compatibility for old test valence accounts that didn't let + // you query the admin + .catch(() => ''), + rebalancerAddress + ? queryClient + .fetchQuery( + valenceRebalancerQueries.getConfig({ + chainId, + contractAddress: rebalancerAddress, + args: { + addr: address, + }, + }) + ) + // This will error when no rebalancer is configured. + .catch(() => null) + : null, + ]) const uniqueDenoms = rebalancerConfig?.targets.map(({ denom }) => denom) || [] // Map token denom to token. @@ -473,6 +491,7 @@ export const fetchValenceAccount = async ( chainId, address, config: { + admin, rebalancer: rebalancerConfig && { config: rebalancerConfig, targets: rebalancerConfig.targets.map((target) => ({ @@ -528,6 +547,40 @@ export const fetchValenceAccounts = async ( ) } +/** + * Fetch a cw1-whitelist account. + */ +export const fetchCw1WhitelistAccount = async ( + queryClient: QueryClient, + { + chainId, + address, + }: { + chainId: string + address: string + } +): Promise => { + const admins = await queryClient.fetchQuery( + cw1WhitelistExtraQueries.adminsIfCw1Whitelist(queryClient, { + chainId, + address, + }) + ) + + if (!admins) { + throw new Error('Not a cw1-whitelist address.') + } + + return { + type: AccountType.Cw1Whitelist, + chainId, + address, + config: { + admins, + }, + } +} + export const accountQueries = { /** * Fetch the list of accounts associated with the specified address. @@ -605,4 +658,15 @@ export const accountQueries = { queryKey: ['account', 'valenceAccounts', options], queryFn: () => fetchValenceAccounts(queryClient, options), }), + /** + * Fetch a cw1-whitelist account. + */ + cw1Whitelist: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['account', 'cw1Whitelist', options], + queryFn: () => fetchCw1WhitelistAccount(queryClient, options), + }), } diff --git a/packages/state/query/queries/chain.ts b/packages/state/query/queries/chain.ts index 506c79ef5..9d8241d10 100644 --- a/packages/state/query/queries/chain.ts +++ b/packages/state/query/queries/chain.ts @@ -4,6 +4,7 @@ import { QueryClient, queryOptions, skipToken } from '@tanstack/react-query' import uniq from 'lodash.uniq' import { + AllGovParams, ChainId, Delegation, GovProposalVersion, @@ -17,23 +18,26 @@ import { import { ModuleAccount } from '@dao-dao/types/protobuf/codegen/cosmos/auth/v1beta1/auth' import { Metadata } from '@dao-dao/types/protobuf/codegen/cosmos/bank/v1beta1/bank' import { DecCoin } from '@dao-dao/types/protobuf/codegen/cosmos/base/v1beta1/coin' -import { ProposalStatus } from '@dao-dao/types/protobuf/codegen/cosmos/gov/v1beta1/gov' import { - NONEXISTENT_QUERY_ERROR_SUBSTRINGS, + ProposalStatus, + TallyResult, + Vote, + WeightedVoteOption, +} from '@dao-dao/types/protobuf/codegen/cosmos/gov/v1beta1/gov' +import { cosmosProtoRpcClientRouter, cosmosSdkVersionIs46OrHigher, cosmosSdkVersionIs47OrHigher, cosmosValidatorToValidator, - cosmwasmProtoRpcClientRouter, decodeGovProposal, feemarketProtoRpcClientRouter, getAllRpcResponse, getCosmWasmClientForChainId, getNativeTokenForChainId, - isSecretNetwork, + ibcProtoRpcClientRouter, + isNonexistentQueryError, isValidBech32Address, osmosisProtoRpcClientRouter, - secretCosmWasmClientRouter, stargateClientRouter, } from '@dao-dao/utils' @@ -41,6 +45,7 @@ import { SearchGovProposalsOptions, searchGovProposals, } from '../../indexer/search' +import { indexerQueries } from './indexer' /** * Fetch the module address associated with the specified name. @@ -405,12 +410,7 @@ export const fetchNativeDelegationInfo = async ( } } catch (err) { // Fails on chains without staking. - if ( - err instanceof Error && - NONEXISTENT_QUERY_ERROR_SUBSTRINGS.some((substring) => - (err as Error).message.includes(substring) - ) - ) { + if (isNonexistentQueryError(err)) { return { delegations: [], unbondingDelegations: [], @@ -422,6 +422,37 @@ export const fetchNativeDelegationInfo = async ( } } +/** + * Fetch the unstaking duration in seconds for the native token. + */ +export const fetchNativeUnstakingDurationSeconds = async ({ + chainId, +}: { + chainId: string +}): Promise => { + // Neutron does not have staking. + if ( + chainId === ChainId.NeutronMainnet || + chainId === ChainId.NeutronTestnet + ) { + return 0 + } + + const client = await cosmosProtoRpcClientRouter.connect(chainId) + try { + const { params } = await client.staking.v1beta1.params() + return Number(params?.unbondingTime?.seconds ?? -1) + } catch (err) { + // Staking unsupported. + if (isNonexistentQueryError(err)) { + return 0 + } + + // Rethrow other errors. + throw err + } +} + /** * Fetch a validator. */ @@ -478,33 +509,6 @@ export const fetchDynamicGasPrice = async ({ return price } -/** - * Fetch the wasm contract-level admin for a contract. - */ -export const fetchWasmContractAdmin = async ({ - chainId, - address, -}: { - chainId: string - address: string -}): Promise => { - if (isSecretNetwork(chainId)) { - const client = await secretCosmWasmClientRouter.connect(chainId) - return (await client.getContract(address))?.admin ?? null - } - - // CosmWasmClient.getContract is not compatible with Terra Classic for some - // reason, so use protobuf query directly. - const client = await cosmwasmProtoRpcClientRouter.connect(chainId) - return ( - ( - await client.wasm.v1.contractInfo({ - address, - }) - )?.contractInfo?.admin ?? null - ) -} - /** * Fetch the on-chain metadata for a denom if it exists. Returns null if denom * not found. This likely exists for token factory denoms. @@ -636,6 +640,113 @@ export const fetchChainSupportsV1GovModule = async ( } } +/** + * Fetch whether or not a chain supports the ICA controller module. + */ +export const fetchSupportsIcaController = async ({ + chainId, +}: { + chainId: string +}): Promise => { + const client = await ibcProtoRpcClientRouter.connect(chainId) + + try { + const { params: { controllerEnabled } = {} } = + await client.applications.interchain_accounts.controller.v1.params() + + return !!controllerEnabled + } catch (err) { + if (isNonexistentQueryError(err)) { + return false + } + + // Rethrow other errors. + throw err + } +} + +/** + * Fetch governance module params. + */ +export const fetchGovParams = async ( + queryClient: QueryClient, + { + chainId, + }: { + chainId: string + } +): Promise => { + const [supportsV1, client] = await Promise.all([ + queryClient.fetchQuery( + chainQueries.supportsV1GovModule(queryClient, { + chainId, + require47: true, + }) + ), + cosmosProtoRpcClientRouter.connect(chainId), + ]) + + if (supportsV1) { + try { + const { params } = await client.gov.v1.params({ + // Does not matter. + paramsType: 'tallying', + }) + if (!params) { + throw new Error('Gov params failed to load') + } + + return { + ...params, + quorum: Number(params.quorum), + threshold: Number(params.threshold), + vetoThreshold: Number(params.vetoThreshold), + minInitialDepositRatio: Number(params.minInitialDepositRatio), + supportsV1, + } + } catch (err) { + // Fallback to v1beta1 query if v1 not supported. + if (!isNonexistentQueryError(err)) { + // Rethrow other errors. + throw err + } + } + } + + // v1beta1 queries are separate + + 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', + }), + ]) + + if (!votingParams || !depositParams || !tallyParams) { + throw new Error('Gov params failed to load') + } + + return { + minDeposit: depositParams.minDeposit, + maxDepositPeriod: depositParams.maxDepositPeriod, + votingPeriod: votingParams.votingPeriod, + quorum: Number(tallyParams.quorum), + threshold: Number(tallyParams.threshold), + vetoThreshold: Number(tallyParams.vetoThreshold), + // Cannot retrieve this from v1beta1 query, so just assume 0.25 as it is a + // conservative estimate. Osmosis uses 0.25 and Juno uses 0.2 as of + // 2023-08-13 + minInitialDepositRatio: 0.25, + supportsV1: false, + } +} + /** * Search chain governance proposals and decode their content. */ @@ -842,6 +953,311 @@ export const fetchGovProposals = async ( } } +/** + * Fetch a chain governance proposal. + */ +export const fetchGovProposal = async ( + queryClient: QueryClient, + { + chainId, + proposalId, + }: { + chainId: string + proposalId: number + } +): Promise => { + const supportsV1 = await queryClient.fetchQuery( + chainQueries.supportsV1GovModule(queryClient, { + chainId, + }) + ) + + // Try to load from indexer first. + const indexerProposal: { + id: string + version: string + data: string + } | null = await queryClient + .fetchQuery( + indexerQueries.queryGeneric(queryClient, { + chainId, + formula: 'gov/proposal', + args: { + id: proposalId, + }, + }) + ) + .catch(() => null) + + let govProposal: GovProposalWithDecodedContent | undefined + + if (indexerProposal) { + if (supportsV1) { + govProposal = await decodeGovProposal(chainId, { + version: GovProposalVersion.V1, + id: BigInt(proposalId), + proposal: ProposalV1.decode(fromBase64(indexerProposal.data)), + }) + } else { + govProposal = await decodeGovProposal(chainId, { + version: GovProposalVersion.V1_BETA_1, + id: BigInt(proposalId), + proposal: ProposalV1Beta1.decode( + fromBase64(indexerProposal.data), + undefined, + true + ), + }) + } + } + + // Fallback to querying chain if indexer failed. + if (!govProposal) { + const client = await cosmosProtoRpcClientRouter.connect(chainId) + + if (supportsV1) { + try { + const proposal = ( + await client.gov.v1.proposal({ + proposalId: BigInt(proposalId), + }) + ).proposal + if (!proposal) { + throw new Error('Proposal not found') + } + + govProposal = await decodeGovProposal(chainId, { + version: GovProposalVersion.V1, + id: BigInt(proposalId), + proposal, + }) + } catch (err) { + // Fallback to v1beta1 query if v1 not supported. + if (!isNonexistentQueryError(err)) { + // Rethrow other errors. + throw err + } + } + } + + if (!govProposal) { + const proposal = ( + await client.gov.v1beta1.proposal( + { + proposalId: BigInt(proposalId), + }, + true + ) + ).proposal + if (!proposal) { + throw new Error('Proposal not found') + } + + govProposal = await decodeGovProposal(chainId, { + version: GovProposalVersion.V1_BETA_1, + id: BigInt(proposalId), + proposal, + }) + } + } + + return govProposal +} + +/** + * Fetch the tally for a chain governance proposal. + */ +export const fetchGovProposalTally = async ( + queryClient: QueryClient, + { + chainId, + proposalId, + }: { + chainId: string + proposalId: number + } +): Promise => { + const [client, supportsV1] = await Promise.all([ + cosmosProtoRpcClientRouter.connect(chainId), + queryClient.fetchQuery( + chainQueries.supportsV1GovModule(queryClient, { + chainId, + }) + ), + ]) + + let tally: TallyResult | undefined + + // Attempt to fetch v1 tally if on v1. + if (supportsV1) { + try { + const { tally: v1Tally } = await client.gov.v1.tallyResult({ + proposalId: BigInt(proposalId), + }) + + // Conform to v1beta1 TallyResult object as it's cleaner. + tally = v1Tally && { + yes: v1Tally.yesCount, + no: v1Tally.noCount, + abstain: v1Tally.abstainCount, + noWithVeto: v1Tally.noWithVetoCount, + } + } catch (err) { + // Fallback to v1beta1 query if v1 failed due to a nonexistent query. + if (!isNonexistentQueryError(err)) { + // Rethrow other errors. + throw err + } + } + } + + if (!tally) { + tally = ( + await client.gov.v1beta1.tallyResult({ + proposalId: BigInt(proposalId), + }) + ).tally + } + + if (!tally) { + throw new Error('Tally not found') + } + + return tally +} + +/** + * Fetch a vote for a chain governance proposal. + */ +export const fetchGovProposalVote = async ( + queryClient: QueryClient, + { + chainId, + proposalId, + voter, + }: { + chainId: string + proposalId: number + voter: string + } +): Promise => { + const [client, supportsV1] = await Promise.all([ + cosmosProtoRpcClientRouter.connect(chainId), + queryClient.fetchQuery( + chainQueries.supportsV1GovModule(queryClient, { + chainId, + }) + ), + ]) + + let vote: WeightedVoteOption[] | undefined + + try { + // Attempt to fetch v1 vote if on v1. + if (supportsV1) { + try { + vote = ( + await client.gov.v1.vote({ + proposalId: BigInt(proposalId), + voter, + }) + ).vote?.options + } catch (err) { + // Fallback to v1beta1 query if v1 failed due to a nonexistent query. + if (!isNonexistentQueryError(err)) { + // Rethrow other errors. + throw err + } + } + } + + if (!vote) { + vote = ( + await client.gov.v1beta1.vote({ + proposalId: BigInt(proposalId), + voter, + }) + ).vote?.options + } + } catch (err) { + // If not found, the voter has not yet voted. + if ( + err instanceof Error && + err.message.includes('not found for proposal') + ) { + return [] + } + + // Rethrow other errors. + throw err + } + + if (!vote) { + throw new Error('Vote not found') + } + + return vote +} + +/** + * Fetch paginated votes for a chain governance proposal. + */ +export const fetchGovProposalVotes = async ( + queryClient: QueryClient, + { + chainId, + proposalId, + offset, + limit, + }: { + chainId: string + proposalId: number + offset: number + limit: number + } +): Promise<{ + /** + * Paginated votes with staked amounts. + */ + votes: (Vote & { staked: bigint })[] + /** + * Total votes cast. + */ + total: number +}> => { + const client = await cosmosProtoRpcClientRouter.connect(chainId) + + const { votes, pagination } = await client.gov.v1beta1.votes({ + proposalId: BigInt(proposalId), + pagination: { + key: new Uint8Array(), + offset: BigInt(offset), + limit: BigInt(limit), + countTotal: true, + reverse: true, + }, + }) + + const stakes = await Promise.all( + votes.map(({ voter }) => + queryClient.fetchQuery( + chainQueries.nativeStakedBalance({ + chainId, + address: voter, + }) + ) + ) + ) + + return { + votes: votes.map((vote, index) => ({ + ...vote, + staked: BigInt(stakes[index].amount), + })), + total: Number(pagination?.total ?? 0), + } +} + export const chainQueries = { /** * Fetch the module address associated with the specified name. @@ -919,6 +1335,16 @@ export const chainQueries = { queryKey: ['chain', 'nativeDelegationInfo', options], queryFn: () => fetchNativeDelegationInfo(queryClient, options), }), + /** + * Fetch the unstaking duration in seconds for the native token. + */ + nativeUnstakingDurationSeconds: ( + options: Parameters[0] + ) => + queryOptions({ + queryKey: ['chain', 'nativeUnstakingDurationSeconds', options], + queryFn: () => fetchNativeUnstakingDurationSeconds(options), + }), /** * Fetch a validator. */ @@ -935,14 +1361,6 @@ export const chainQueries = { queryKey: ['chain', 'dynamicGasPrice', options], queryFn: () => fetchDynamicGasPrice(options), }), - /** - * Fetch the wasm contract-level admin for a contract. - */ - wasmContractAdmin: (options: Parameters[0]) => - queryOptions({ - queryKey: ['chain', 'wasmContractAdmin', options], - queryFn: () => fetchWasmContractAdmin(options), - }), /** * Fetch the on-chain metadata for a denom if it exists. */ @@ -972,6 +1390,27 @@ export const chainQueries = { queryKey: ['chain', 'supportsV1GovModule', options], queryFn: () => fetchChainSupportsV1GovModule(queryClient, options), }), + /** + * Fetch whether or not a chain supports the ICA controller module. + */ + supportsIcaController: ( + options: Parameters[0] + ) => + queryOptions({ + queryKey: ['chain', 'supportsIcaController', options], + queryFn: () => fetchSupportsIcaController(options), + }), + /** + * Fetch governance module params. + */ + govParams: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['chain', 'govParams', options], + queryFn: () => fetchGovParams(queryClient, options), + }), /** * Search chain governance proposals. */ @@ -1003,4 +1442,48 @@ export const chainQueries = { queryKey: ['chain', 'govProposals', options], queryFn: () => fetchGovProposals(queryClient, options), }), + /** + * Fetch a chain governance proposal. + */ + govProposal: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['chain', 'govProposal', options], + queryFn: () => fetchGovProposal(queryClient, options), + }), + /** + * Fetch the tally for a chain governance proposal. + */ + govProposalTally: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['chain', 'govProposalTally', options], + queryFn: () => fetchGovProposalTally(queryClient, options), + }), + /** + * Fetch a vote for a chain governance proposal. + */ + govProposalVote: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['chain', 'govProposalVote', options], + queryFn: () => fetchGovProposalVote(queryClient, options), + }), + /** + * Fetch paginated votes for a chain governance proposal. + */ + govProposalVotes: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['chain', 'govProposalVotes', options], + queryFn: () => fetchGovProposalVotes(queryClient, options), + }), } diff --git a/packages/state/query/queries/contract.ts b/packages/state/query/queries/contract.ts index 1eb96c2e3..1058334d7 100644 --- a/packages/state/query/queries/contract.ts +++ b/packages/state/query/queries/contract.ts @@ -7,10 +7,10 @@ import { AccessType } from '@dao-dao/types/protobuf/codegen/cosmwasm/wasm/v1/typ import { ContractName, DAO_CORE_CONTRACT_NAMES, - INVALID_CONTRACT_ERROR_SUBSTRINGS, cosmwasmProtoRpcClientRouter, getChainForChainId, getCosmWasmClientForChainId, + isInvalidContractError, isSecretNetwork, isValidBech32Address, objectMatchesStructure, @@ -128,20 +128,14 @@ export const fetchIsContract = async ( : contract.includes(nameOrNames) } catch (err) { if ( - err instanceof Error && - INVALID_CONTRACT_ERROR_SUBSTRINGS.some((substring) => - (err as Error).message.includes(substring) - ) + isInvalidContractError(err) || + // On Secret Network, just return false, since there are weird failures + // for failed contract queries. + isSecretNetwork(chainId) ) { return false } - // On Secret Network, just return, since there are weird failures for - // failed contract queries. - if (isSecretNetwork(chainId)) { - return false - } - // Rethrow other errors because it should not have failed. throw err } @@ -239,6 +233,33 @@ export const fetchContractCodeInfo = async ({ return codeInfo } +/** + * Fetch the wasm contract-level admin for a contract. + */ +export const fetchContractAdmin = async ({ + chainId, + address, +}: { + chainId: string + address: string +}): Promise => { + if (isSecretNetwork(chainId)) { + const client = await secretCosmWasmClientRouter.connect(chainId) + return (await client.getContract(address))?.admin ?? null + } + + // CosmWasmClient.getContract is not compatible with Terra Classic for some + // reason, so use protobuf query directly. + const client = await cosmwasmProtoRpcClientRouter.connect(chainId) + return ( + ( + await client.wasm.v1.contractInfo({ + address, + }) + )?.contractInfo?.admin ?? null + ) +} + /** * Get code hash for a Secret Network contract. */ @@ -344,6 +365,14 @@ export const contractQueries = { queryKey: ['contract', 'codeInfo', options], queryFn: () => fetchContractCodeInfo(options), }), + /* + * Fetch the wasm contract-level admin for a contract. + */ + admin: (options: Parameters[0]) => + queryOptions({ + queryKey: ['contract', 'admin', options], + queryFn: () => fetchContractAdmin(options), + }), /** * Fetch the code hash for a Secret Network contract. */ diff --git a/packages/state/query/queries/contracts/Cw20Stake.extra.ts b/packages/state/query/queries/contracts/Cw20Stake.extra.ts new file mode 100644 index 000000000..f545fd0fd --- /dev/null +++ b/packages/state/query/queries/contracts/Cw20Stake.extra.ts @@ -0,0 +1,150 @@ +import { QueryClient, queryOptions } from '@tanstack/react-query' + +import { ConfigResponse as OraichainCw20StakingProxySnapshotConfigResponse } from '@dao-dao/types/contracts/OraichainCw20StakingProxySnapshot' +import { ContractName, getCosmWasmClientForChainId } from '@dao-dao/utils' + +import { contractQueries } from '../contract' +import { indexerQueries } from '../indexer' + +/** + * Get config for Oraichain's cw20-staking-proxy-snapshot contract. + */ +export const fetchOraichainProxySnapshotConfig = async ( + queryClient: QueryClient, + { + chainId, + address, + }: { + chainId: string + address: string + } +): Promise => { + const isOraichainProxy = await queryClient.fetchQuery( + cw20StakeExtraQueries.isOraichainProxySnapshotContract(queryClient, { + chainId, + address, + }) + ) + if (!isOraichainProxy) { + throw new Error( + 'Contract is not an Oraichain cw20-staking proxy-snapshot contract' + ) + } + + const config = await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress: address, + formula: 'item', + args: { + key: 'config', + }, + }) + ) + if (config) { + return config + } + + // If indexer fails, fallback to querying chain. + return await ( + await getCosmWasmClientForChainId(chainId) + ).queryContractSmart(address, { + config: {}, + }) +} + +/** + * Fetch cw20-stake top stakers. + */ +export const fetchCw20StakeTopStakers = async ( + queryClient: QueryClient, + { + chainId, + address, + limit, + }: { + chainId: string + address: string + limit?: number + } +): Promise< + { + address: string + balance: string + }[] +> => { + // If Oraichain proxy, get staking token and pass to indexer query. + let oraichainStakingToken: string | undefined + const isOraichainProxy = await queryClient.fetchQuery( + cw20StakeExtraQueries.isOraichainProxySnapshotContract(queryClient, { + chainId, + address, + }) + ) + if (isOraichainProxy) { + oraichainStakingToken = ( + await queryClient.fetchQuery( + cw20StakeExtraQueries.oraichainProxySnapshotConfig(queryClient, { + chainId, + address, + }) + ) + ).asset_key + } + + return ( + (await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress: address, + formula: 'cw20Stake/topStakers', + args: { + ...(limit && { args: { limit } }), + oraichainStakingToken, + }, + noFallback: true, + }) + )) || [] + ) +} + +export const cw20StakeExtraQueries = { + /** + * The Oraichain cw20-staking-proxy-snapshot contract is used as the staking + * contract for their custom staking solution. This selector returns whether + * or not this is a cw20-staking-proxy-snapshot contract. + */ + isOraichainProxySnapshotContract: ( + queryClient: QueryClient, + options: { + chainId: string + address: string + } + ) => + contractQueries.isContract(queryClient, { + ...options, + nameOrNames: ContractName.OraichainCw20StakingProxySnapshot, + }), + /** + * Get config for Oraichain's cw20-staking-proxy-snapshot contract. + */ + oraichainProxySnapshotConfig: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['cw20StakeExtra', 'oraichainProxySnapshotConfig', options], + queryFn: () => fetchOraichainProxySnapshotConfig(queryClient, options), + }), + /** + * Fetch cw20-stake top stakers. + */ + topStakers: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['cw20StakeExtra', 'topStakers', options], + queryFn: () => fetchCw20StakeTopStakers(queryClient, options), + }), +} diff --git a/packages/state/query/queries/contracts/Cw20Stake.ts b/packages/state/query/queries/contracts/Cw20Stake.ts new file mode 100644 index 000000000..e379e7495 --- /dev/null +++ b/packages/state/query/queries/contracts/Cw20Stake.ts @@ -0,0 +1,633 @@ +/** + * This file was automatically generated by @cosmwasm/ts-codegen@1.10.0. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run the @cosmwasm/ts-codegen generate command to regenerate this file. + */ + +import { QueryClient, UseQueryOptions } from '@tanstack/react-query' + +import { + Claim, + ClaimsResponse, + Config, + GetHooksResponse, + ListStakersResponse, + OwnershipForAddr, + StakedBalanceAtHeightResponse, + StakedValueResponse, + TotalStakedAtHeightResponse, + TotalValueResponse, +} from '@dao-dao/types/contracts/Cw20Stake' +import { getCosmWasmClientForChainId } from '@dao-dao/utils' + +import { Cw20StakeQueryClient } from '../../../contracts/Cw20Stake' +import { indexerQueries } from '../indexer' +import { cw20StakeExtraQueries } from './Cw20Stake.extra' +import { oraichainCw20StakingExtraQueries } from './OraichainCw20Staking.extra' + +export const cw20StakeQueryKeys = { + contract: [ + { + contract: 'cw20Stake', + }, + ] as const, + address: (chainId: string, contractAddress: string) => + [ + { + ...cw20StakeQueryKeys.contract[0], + chainId, + address: contractAddress, + }, + ] as const, + stakedBalanceAtHeight: ( + chainId: string, + contractAddress: string, + args?: Record + ) => + [ + { + ...cw20StakeQueryKeys.address(chainId, contractAddress)[0], + method: 'staked_balance_at_height', + args, + }, + ] as const, + totalStakedAtHeight: ( + chainId: string, + contractAddress: string, + args?: Record + ) => + [ + { + ...cw20StakeQueryKeys.address(chainId, contractAddress)[0], + method: 'total_staked_at_height', + args, + }, + ] as const, + stakedValue: ( + chainId: string, + contractAddress: string, + args?: Record + ) => + [ + { + ...cw20StakeQueryKeys.address(chainId, contractAddress)[0], + method: 'staked_value', + args, + }, + ] as const, + totalValue: ( + chainId: string, + contractAddress: string, + args?: Record + ) => + [ + { + ...cw20StakeQueryKeys.address(chainId, contractAddress)[0], + method: 'total_value', + args, + }, + ] as const, + getConfig: ( + chainId: string, + contractAddress: string, + args?: Record + ) => + [ + { + ...cw20StakeQueryKeys.address(chainId, contractAddress)[0], + method: 'get_config', + args, + }, + ] as const, + claims: ( + chainId: string, + contractAddress: string, + args?: Record + ) => + [ + { + ...cw20StakeQueryKeys.address(chainId, contractAddress)[0], + method: 'claims', + args, + }, + ] as const, + getHooks: ( + chainId: string, + contractAddress: string, + args?: Record + ) => + [ + { + ...cw20StakeQueryKeys.address(chainId, contractAddress)[0], + method: 'get_hooks', + args, + }, + ] as const, + listStakers: ( + chainId: string, + contractAddress: string, + args?: Record + ) => + [ + { + ...cw20StakeQueryKeys.address(chainId, contractAddress)[0], + method: 'list_stakers', + args, + }, + ] as const, + ownership: ( + chainId: string, + contractAddress: string, + args?: Record + ) => + [ + { + ...cw20StakeQueryKeys.address(chainId, contractAddress)[0], + method: 'ownership', + args, + }, + ] as const, +} +export const cw20StakeQueries = { + stakedBalanceAtHeight: ( + queryClient: QueryClient, + { + chainId, + contractAddress, + args, + options, + }: Cw20StakeStakedBalanceAtHeightQuery + ): UseQueryOptions => ({ + queryKey: cw20StakeQueryKeys.stakedBalanceAtHeight( + chainId, + contractAddress, + args + ), + queryFn: async () => { + // If Oraichain proxy, get staking token and pass to indexer query. + let oraichainStakingToken: string | undefined + const isOraichainProxy = await queryClient.fetchQuery( + cw20StakeExtraQueries.isOraichainProxySnapshotContract(queryClient, { + chainId, + address: contractAddress, + }) + ) + if (isOraichainProxy) { + oraichainStakingToken = ( + await queryClient.fetchQuery( + cw20StakeExtraQueries.oraichainProxySnapshotConfig(queryClient, { + chainId, + address: contractAddress, + }) + ) + ).asset_key + } + + try { + // Attempt to fetch data from the indexer. + return await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress, + formula: 'cw20Stake/stakedBalanceAtHeight', + args: { + address: args.address, + ...(oraichainStakingToken && { oraichainStakingToken }), + }, + ...(args.height && { block: { height: args.height } }), + }) + ) + } catch (error) { + console.error(error) + } + + // If indexer query fails, fallback to contract query. + return new Cw20StakeQueryClient( + await getCosmWasmClientForChainId(chainId), + contractAddress + ).stakedBalanceAtHeight({ + address: args.address, + height: args.height, + }) + }, + ...options, + }), + totalStakedAtHeight: ( + queryClient: QueryClient, + { + chainId, + contractAddress, + args, + options, + }: Cw20StakeTotalStakedAtHeightQuery + ): UseQueryOptions => ({ + queryKey: cw20StakeQueryKeys.totalStakedAtHeight( + chainId, + contractAddress, + args + ), + queryFn: async () => { + // If Oraichain proxy, get staking token and pass to indexer query. + let oraichainStakingToken: string | undefined + const isOraichainProxy = await queryClient.fetchQuery( + cw20StakeExtraQueries.isOraichainProxySnapshotContract(queryClient, { + chainId, + address: contractAddress, + }) + ) + if (isOraichainProxy) { + oraichainStakingToken = ( + await queryClient.fetchQuery( + cw20StakeExtraQueries.oraichainProxySnapshotConfig(queryClient, { + chainId, + address: contractAddress, + }) + ) + ).asset_key + } + + try { + // Attempt to fetch data from the indexer. + return await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress, + formula: 'cw20Stake/totalStakedAtHeight', + args: { + ...(oraichainStakingToken && { oraichainStakingToken }), + }, + ...(args.height && { block: { height: args.height } }), + }) + ) + } catch (error) { + console.error(error) + } + + // If indexer query fails, fallback to contract query. + return new Cw20StakeQueryClient( + await getCosmWasmClientForChainId(chainId), + contractAddress + ).totalStakedAtHeight({ + height: args.height, + }) + }, + ...options, + }), + stakedValue: ( + queryClient: QueryClient, + { + chainId, + contractAddress, + args, + options, + }: Cw20StakeStakedValueQuery + ): UseQueryOptions => ({ + queryKey: cw20StakeQueryKeys.stakedValue(chainId, contractAddress, args), + queryFn: async () => { + // Oraichain proxy handles passing the query through. + const isOraichainProxy = await queryClient.fetchQuery( + cw20StakeExtraQueries.isOraichainProxySnapshotContract(queryClient, { + chainId, + address: contractAddress, + }) + ) + if (isOraichainProxy) { + return { + value: ( + await queryClient.fetchQuery( + cw20StakeQueries.stakedBalanceAtHeight(queryClient, { + chainId, + contractAddress, + args, + }) + ) + ).balance, + } + } + + try { + // Attempt to fetch data from the indexer. + return { + value: await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress, + formula: 'cw20Stake/stakedValue', + args, + }) + ), + } + } catch (error) { + console.error(error) + } + + // If indexer query fails, fallback to contract query. + return new Cw20StakeQueryClient( + await getCosmWasmClientForChainId(chainId), + contractAddress + ).stakedValue({ + address: args.address, + }) + }, + ...options, + }), + totalValue: ( + queryClient: QueryClient, + { chainId, contractAddress, options }: Cw20StakeTotalValueQuery + ): UseQueryOptions => ({ + queryKey: cw20StakeQueryKeys.totalValue(chainId, contractAddress), + queryFn: async () => { + // Oraichain proxy handles passing the query through. + const isOraichainProxy = await queryClient.fetchQuery( + cw20StakeExtraQueries.isOraichainProxySnapshotContract(queryClient, { + chainId, + address: contractAddress, + }) + ) + if (isOraichainProxy) { + return { + total: ( + await queryClient.fetchQuery( + cw20StakeQueries.totalStakedAtHeight(queryClient, { + chainId, + contractAddress, + args: {}, + }) + ) + ).total, + } + } + + try { + // Attempt to fetch data from the indexer. + return { + total: await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress, + formula: 'cw20Stake/totalValue', + }) + ), + } + } catch (error) { + console.error(error) + } + + // If indexer query fails, fallback to contract query. + return new Cw20StakeQueryClient( + await getCosmWasmClientForChainId(chainId), + contractAddress + ).totalValue() + }, + ...options, + }), + getConfig: ( + queryClient: QueryClient, + { chainId, contractAddress, options }: Cw20StakeGetConfigQuery + ): UseQueryOptions => ({ + queryKey: cw20StakeQueryKeys.getConfig(chainId, contractAddress), + queryFn: async () => { + const isOraichainProxy = await queryClient.fetchQuery( + cw20StakeExtraQueries.isOraichainProxySnapshotContract(queryClient, { + chainId, + address: contractAddress, + }) + ) + + // Oraichain proxy handles passing the query through. + if (!isOraichainProxy) { + try { + // Attempt to fetch data from the indexer. + return await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress, + formula: 'cw20Stake/config', + }) + ) + } catch (error) { + console.error(error) + } + } + + // If indexer query fails, fallback to contract query. + return new Cw20StakeQueryClient( + await getCosmWasmClientForChainId(chainId), + contractAddress + ).getConfig() + }, + ...options, + }), + claims: ( + queryClient: QueryClient, + { chainId, contractAddress, args, options }: Cw20StakeClaimsQuery + ): UseQueryOptions => ({ + queryKey: cw20StakeQueryKeys.claims(chainId, contractAddress, args), + queryFn: async () => { + // Convert Oraichain lock infos to claims. + const isOraichainProxy = await queryClient.fetchQuery( + cw20StakeExtraQueries.isOraichainProxySnapshotContract(queryClient, { + chainId, + address: contractAddress, + }) + ) + if (isOraichainProxy) { + const { asset_key, staking_contract } = await queryClient.fetchQuery( + cw20StakeExtraQueries.oraichainProxySnapshotConfig(queryClient, { + chainId, + address: contractAddress, + }) + ) + const { lock_infos } = await queryClient.fetchQuery( + oraichainCw20StakingExtraQueries.listAllLockInfos(queryClient, { + chainId, + address: staking_contract, + stakerAddr: args.address, + stakingToken: asset_key, + }) + ) + + return { + claims: lock_infos.map( + ({ amount, unlock_time }): Claim => ({ + amount, + release_at: { + // Convert seconds to nanoseconds. + at_time: (BigInt(unlock_time) * BigInt(1e9)).toString(), + }, + }) + ), + } + } + + try { + // Attempt to fetch data from the indexer. + return { + claims: await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress, + formula: 'cw20Stake/claims', + args, + }) + ), + } + } catch (error) { + console.error(error) + } + + // If indexer query fails, fallback to contract query. + return new Cw20StakeQueryClient( + await getCosmWasmClientForChainId(chainId), + contractAddress + ).claims({ + address: args.address, + }) + }, + ...options, + }), + getHooks: ({ + chainId, + contractAddress, + options, + }: Cw20StakeGetHooksQuery): UseQueryOptions< + GetHooksResponse, + Error, + TData + > => ({ + queryKey: cw20StakeQueryKeys.getHooks(chainId, contractAddress), + queryFn: async () => { + return new Cw20StakeQueryClient( + await getCosmWasmClientForChainId(chainId), + contractAddress + ).getHooks() + }, + ...options, + }), + listStakers: ( + queryClient: QueryClient, + { + chainId, + contractAddress, + args, + options, + }: Cw20StakeListStakersQuery + ): UseQueryOptions => ({ + queryKey: cw20StakeQueryKeys.listStakers(chainId, contractAddress, args), + queryFn: async () => { + // Oraichain has their own interface. + const isOraichainProxy = await queryClient.fetchQuery( + cw20StakeExtraQueries.isOraichainProxySnapshotContract(queryClient, { + chainId, + address: contractAddress, + }) + ) + if (isOraichainProxy) { + return { stakers: [] } + } + + try { + // Attempt to fetch data from the indexer. + return { + stakers: await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress, + formula: 'cw20Stake/listStakers', + args, + }) + ), + } + } catch (error) { + console.error(error) + } + + // If indexer query fails, fallback to contract query. + return new Cw20StakeQueryClient( + await getCosmWasmClientForChainId(chainId), + contractAddress + ).listStakers({ + limit: args.limit, + startAfter: args.startAfter, + }) + }, + ...options, + }), + ownership: ( + queryClient: QueryClient, + { chainId, contractAddress, options }: Cw20StakeOwnershipQuery + ): UseQueryOptions => ({ + queryKey: cw20StakeQueryKeys.ownership(chainId, contractAddress), + queryFn: async () => { + try { + // Attempt to fetch data from the indexer. + return await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress, + formula: 'cw20Stake/ownership', + }) + ) + } catch (error) { + console.error(error) + } + + // If indexer query fails, fallback to contract query. + return new Cw20StakeQueryClient( + await getCosmWasmClientForChainId(chainId), + contractAddress + ).ownership() + }, + ...options, + }), +} +export interface Cw20StakeReactQuery { + chainId: string + contractAddress: string + options?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' | 'initialData' + > & { + initialData?: undefined + } +} +export interface Cw20StakeOwnershipQuery + extends Cw20StakeReactQuery {} +export interface Cw20StakeListStakersQuery + extends Cw20StakeReactQuery { + args: { + limit?: number + startAfter?: string + } +} +export interface Cw20StakeGetHooksQuery + extends Cw20StakeReactQuery {} +export interface Cw20StakeClaimsQuery + extends Cw20StakeReactQuery { + args: { + address: string + } +} +export interface Cw20StakeGetConfigQuery + extends Cw20StakeReactQuery {} +export interface Cw20StakeTotalValueQuery + extends Cw20StakeReactQuery {} +export interface Cw20StakeStakedValueQuery + extends Cw20StakeReactQuery { + args: { + address: string + } +} +export interface Cw20StakeTotalStakedAtHeightQuery + extends Cw20StakeReactQuery { + args: { + height?: number + } +} +export interface Cw20StakeStakedBalanceAtHeightQuery + extends Cw20StakeReactQuery { + args: { + address: string + height?: number + } +} diff --git a/packages/state/query/queries/contracts/DaoVotingTokenStaked.extra.ts b/packages/state/query/queries/contracts/DaoVotingTokenStaked.extra.ts new file mode 100644 index 000000000..2c6c1e9d6 --- /dev/null +++ b/packages/state/query/queries/contracts/DaoVotingTokenStaked.extra.ts @@ -0,0 +1,77 @@ +import { QueryClient, queryOptions } from '@tanstack/react-query' + +import { ContractName } from '@dao-dao/utils' + +import { contractQueries } from '../contract' +import { daoVotingTokenStakedQueries } from './DaoVotingTokenStaked' + +/** + * Returns the cw-tokenfactory-issuer contract address if this voting module + * uses a token factory denom and uses a cw-tokenfactory-issuer contract. + */ +export const fetchValidatedTokenfactoryIssuerContract = async ( + queryClient: QueryClient, + { + chainId, + address, + }: { + chainId: string + address: string + } +): Promise => { + const { denom } = await queryClient.fetchQuery( + daoVotingTokenStakedQueries.denom(queryClient, { + chainId, + contractAddress: address, + }) + ) + + if (!denom.startsWith('factory/')) { + return null + } + + const tokenContract = await queryClient.fetchQuery( + daoVotingTokenStakedQueries.tokenContract(queryClient, { + chainId, + contractAddress: address, + }) + ) + + if (!tokenContract) { + return null + } + + const isTfIssuer = await queryClient.fetchQuery( + contractQueries.isContract(queryClient, { + chainId, + address: tokenContract, + nameOrNames: ContractName.CwTokenfactoryIssuer, + }) + ) + + if (!isTfIssuer) { + return null + } + + return tokenContract +} + +export const daoVotingTokenStakedExtraQueries = { + /** + * Returns the cw-tokenfactory-issuer contract address if this voting module + * uses a token factory denom and uses a cw-tokenfactory-issuer contract. + */ + validatedTokenfactoryIssuerContract: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: [ + 'daoVotingTokenStakedExtra', + 'validatedTokenfactoryIssuerContract', + options, + ], + queryFn: () => + fetchValidatedTokenfactoryIssuerContract(queryClient, options), + }), +} diff --git a/packages/state/query/queries/contracts/NeutronCwdSubdaoTimelockSingle.ts b/packages/state/query/queries/contracts/NeutronCwdSubdaoTimelockSingle.ts new file mode 100644 index 000000000..a927e409b --- /dev/null +++ b/packages/state/query/queries/contracts/NeutronCwdSubdaoTimelockSingle.ts @@ -0,0 +1,291 @@ +/** + * This file was automatically generated by @cosmwasm/ts-codegen@1.10.0. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run the @cosmwasm/ts-codegen generate command to regenerate this file. + */ + +import { QueryClient, UseQueryOptions } from '@tanstack/react-query' + +import { + Config, + NullableString, + ProposalListResponse, + SingleChoiceProposal, +} from '@dao-dao/types/contracts/NeutronCwdSubdaoTimelockSingle' +import { getCosmWasmClientForChainId } from '@dao-dao/utils' + +import { NeutronCwdSubdaoTimelockSingleQueryClient } from '../../../contracts/NeutronCwdSubdaoTimelockSingle' +import { indexerQueries } from '../indexer' + +export const neutronCwdSubdaoTimelockSingleQueryKeys = { + contract: [ + { + contract: 'neutronCwdSubdaoTimelockSingle', + }, + ] as const, + address: (chainId: string, contractAddress: string) => + [ + { + ...neutronCwdSubdaoTimelockSingleQueryKeys.contract[0], + chainId, + address: contractAddress, + }, + ] as const, + config: ( + chainId: string, + contractAddress: string, + args?: Record + ) => + [ + { + ...neutronCwdSubdaoTimelockSingleQueryKeys.address( + chainId, + contractAddress + )[0], + method: 'config', + args, + }, + ] as const, + proposal: ( + chainId: string, + contractAddress: string, + args?: Record + ) => + [ + { + ...neutronCwdSubdaoTimelockSingleQueryKeys.address( + chainId, + contractAddress + )[0], + method: 'proposal', + args, + }, + ] as const, + listProposals: ( + chainId: string, + contractAddress: string, + args?: Record + ) => + [ + { + ...neutronCwdSubdaoTimelockSingleQueryKeys.address( + chainId, + contractAddress + )[0], + method: 'list_proposals', + args, + }, + ] as const, + proposalExecutionError: ( + chainId: string, + contractAddress: string, + args?: Record + ) => + [ + { + ...neutronCwdSubdaoTimelockSingleQueryKeys.address( + chainId, + contractAddress + )[0], + method: 'proposal_execution_error', + args, + }, + ] as const, +} +export const neutronCwdSubdaoTimelockSingleQueries = { + config: ( + queryClient: QueryClient, + { + chainId, + contractAddress, + options, + }: NeutronCwdSubdaoTimelockSingleConfigQuery + ): UseQueryOptions => ({ + queryKey: neutronCwdSubdaoTimelockSingleQueryKeys.config( + chainId, + contractAddress + ), + queryFn: async () => { + try { + // Attempt to fetch data from the indexer. + return await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress, + formula: 'neutron/cwdSubdaoTimelockSingle/config', + }) + ) + } catch (error) { + console.error(error) + } + + // If indexer query fails, fallback to contract query. + return new NeutronCwdSubdaoTimelockSingleQueryClient( + await getCosmWasmClientForChainId(chainId), + contractAddress + ).config() + }, + ...options, + }), + proposal: ( + queryClient: QueryClient, + { + chainId, + contractAddress, + args, + options, + }: NeutronCwdSubdaoTimelockSingleProposalQuery + ): UseQueryOptions => ({ + queryKey: neutronCwdSubdaoTimelockSingleQueryKeys.proposal( + chainId, + contractAddress, + args + ), + queryFn: async () => { + try { + // Attempt to fetch data from the indexer. + return await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress, + formula: 'neutron/cwdSubdaoTimelockSingle/proposal', + args, + }) + ) + } catch (error) { + console.error(error) + } + + // If indexer query fails, fallback to contract query. + return new NeutronCwdSubdaoTimelockSingleQueryClient( + await getCosmWasmClientForChainId(chainId), + contractAddress + ).proposal({ + proposalId: args.proposalId, + }) + }, + ...options, + }), + listProposals: ( + queryClient: QueryClient, + { + chainId, + contractAddress, + args, + options, + }: NeutronCwdSubdaoTimelockSingleListProposalsQuery + ): UseQueryOptions => ({ + queryKey: neutronCwdSubdaoTimelockSingleQueryKeys.listProposals( + chainId, + contractAddress, + args + ), + queryFn: async () => { + try { + // Attempt to fetch data from the indexer. + return { + proposals: await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress, + formula: 'neutron/cwdSubdaoTimelockSingle/listProposals', + args, + }) + ), + } + } catch (error) { + console.error(error) + } + + // If indexer query fails, fallback to contract query. + return new NeutronCwdSubdaoTimelockSingleQueryClient( + await getCosmWasmClientForChainId(chainId), + contractAddress + ).listProposals({ + limit: args.limit, + startAfter: args.startAfter, + }) + }, + ...options, + }), + proposalExecutionError: ( + queryClient: QueryClient, + { + chainId, + contractAddress, + args, + options, + }: NeutronCwdSubdaoTimelockSingleProposalExecutionErrorQuery + ): UseQueryOptions => ({ + queryKey: neutronCwdSubdaoTimelockSingleQueryKeys.proposalExecutionError( + chainId, + contractAddress, + args + ), + queryFn: async () => { + try { + // Attempt to fetch data from the indexer. + return await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress, + formula: 'neutron/cwdSubdaoTimelockSingle/proposalExecutionError', + args, + }) + ) + } catch (error) { + console.error(error) + } + + // If indexer query fails, fallback to contract query. + return new NeutronCwdSubdaoTimelockSingleQueryClient( + await getCosmWasmClientForChainId(chainId), + contractAddress + ).proposalExecutionError({ + proposalId: args.proposalId, + }) + }, + ...options, + }), +} +export interface NeutronCwdSubdaoTimelockSingleReactQuery< + TResponse, + TData = TResponse +> { + chainId: string + contractAddress: string + options?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' | 'initialData' + > & { + initialData?: undefined + } +} +export interface NeutronCwdSubdaoTimelockSingleProposalExecutionErrorQuery< + TData +> extends NeutronCwdSubdaoTimelockSingleReactQuery { + args: { + proposalId: number + } +} +export interface NeutronCwdSubdaoTimelockSingleListProposalsQuery + extends NeutronCwdSubdaoTimelockSingleReactQuery< + ProposalListResponse, + TData + > { + args: { + limit?: number + startAfter?: number + } +} +export interface NeutronCwdSubdaoTimelockSingleProposalQuery + extends NeutronCwdSubdaoTimelockSingleReactQuery< + SingleChoiceProposal, + TData + > { + args: { + proposalId: number + } +} +export interface NeutronCwdSubdaoTimelockSingleConfigQuery + extends NeutronCwdSubdaoTimelockSingleReactQuery {} diff --git a/packages/state/query/queries/contracts/OraichainCw20Staking.extra.ts b/packages/state/query/queries/contracts/OraichainCw20Staking.extra.ts new file mode 100644 index 000000000..e69064ae1 --- /dev/null +++ b/packages/state/query/queries/contracts/OraichainCw20Staking.extra.ts @@ -0,0 +1,79 @@ +import { QueryClient, queryOptions } from '@tanstack/react-query' + +import { LockInfosResponse } from '@dao-dao/types/contracts/OraichainCw20Staking' + +import { OraichainCw20StakingQueryClient } from '../../../contracts/OraichainCw20Staking' +import { oraichainCw20StakingQueries } from './OraichainCw20Staking' + +/** + * List all lock infos for Oraichain's cw20-staking contract. + */ +export const listAllOraichainCw20StakingLockInfos = async ( + queryClient: QueryClient, + { + chainId, + address, + stakerAddr, + stakingToken, + }: { + chainId: string + address: string + } & Pick< + Parameters[0], + 'stakerAddr' | 'stakingToken' + > +): Promise => { + const response: LockInfosResponse = { + lock_infos: [], + staker_addr: '', + staking_token: '', + } + + const limit = 30 + while (true) { + const page = await queryClient.fetchQuery( + oraichainCw20StakingQueries.lockInfos({ + chainId, + contractAddress: address, + args: { + stakerAddr, + stakingToken, + order: 1, // descending + startAfter: + response.lock_infos[response.lock_infos.length - 1]?.unlock_time, + limit, + }, + }) + ) + + if (!page.staker_addr) { + response.staker_addr = page.staker_addr + } + if (!page.staking_token) { + response.staking_token = page.staking_token + } + + response.lock_infos.push(...page.lock_infos) + + // If we have less than the limit of items, we've exhausted them. + if (response.lock_infos.length < limit) { + break + } + } + + return response +} + +export const oraichainCw20StakingExtraQueries = { + /** + * Get all lock infos for Oraichain's cw20-staking contract. + */ + listAllLockInfos: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['oraichainCw20StakingExtra', 'listAllLockInfos', options], + queryFn: () => listAllOraichainCw20StakingLockInfos(queryClient, options), + }), +} diff --git a/packages/state/query/queries/contracts/OraichainCw20Staking.ts b/packages/state/query/queries/contracts/OraichainCw20Staking.ts new file mode 100644 index 000000000..e8a776a05 --- /dev/null +++ b/packages/state/query/queries/contracts/OraichainCw20Staking.ts @@ -0,0 +1,441 @@ +/** + * This file was automatically generated by @cosmwasm/ts-codegen@1.10.0. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run the @cosmwasm/ts-codegen generate command to regenerate this file. + */ + +import { UseQueryOptions } from '@tanstack/react-query' + +import { + Addr, + ArrayOfQueryPoolInfoResponse, + ArrayOfRewardInfoResponse, + ConfigResponse, + LockInfosResponse, + PoolInfoResponse, + RewardInfoResponse, + RewardsPerSecResponse, + StakedBalanceAtHeightResponse, + TotalStakedAtHeightResponse, +} from '@dao-dao/types/contracts/OraichainCw20Staking' +import { getCosmWasmClientForChainId } from '@dao-dao/utils' + +import { OraichainCw20StakingQueryClient } from '../../../contracts/OraichainCw20Staking' + +export const oraichainCw20StakingQueryKeys = { + contract: [ + { + contract: 'oraichainCw20Staking', + }, + ] as const, + address: (chainId: string, contractAddress: string) => + [ + { + ...oraichainCw20StakingQueryKeys.contract[0], + chainId, + address: contractAddress, + }, + ] as const, + config: ( + chainId: string, + contractAddress: string, + args?: Record + ) => + [ + { + ...oraichainCw20StakingQueryKeys.address(chainId, contractAddress)[0], + method: 'config', + args, + }, + ] as const, + poolInfo: ( + chainId: string, + contractAddress: string, + args?: Record + ) => + [ + { + ...oraichainCw20StakingQueryKeys.address(chainId, contractAddress)[0], + method: 'pool_info', + args, + }, + ] as const, + rewardsPerSec: ( + chainId: string, + contractAddress: string, + args?: Record + ) => + [ + { + ...oraichainCw20StakingQueryKeys.address(chainId, contractAddress)[0], + method: 'rewards_per_sec', + args, + }, + ] as const, + rewardInfo: ( + chainId: string, + contractAddress: string, + args?: Record + ) => + [ + { + ...oraichainCw20StakingQueryKeys.address(chainId, contractAddress)[0], + method: 'reward_info', + args, + }, + ] as const, + rewardInfos: ( + chainId: string, + contractAddress: string, + args?: Record + ) => + [ + { + ...oraichainCw20StakingQueryKeys.address(chainId, contractAddress)[0], + method: 'reward_infos', + args, + }, + ] as const, + getPoolsInformation: ( + chainId: string, + contractAddress: string, + args?: Record + ) => + [ + { + ...oraichainCw20StakingQueryKeys.address(chainId, contractAddress)[0], + method: 'get_pools_information', + args, + }, + ] as const, + lockInfos: ( + chainId: string, + contractAddress: string, + args?: Record + ) => + [ + { + ...oraichainCw20StakingQueryKeys.address(chainId, contractAddress)[0], + method: 'lock_infos', + args, + }, + ] as const, + stakedBalanceAtHeight: ( + chainId: string, + contractAddress: string, + args?: Record + ) => + [ + { + ...oraichainCw20StakingQueryKeys.address(chainId, contractAddress)[0], + method: 'staked_balance_at_height', + args, + }, + ] as const, + totalStakedAtHeight: ( + chainId: string, + contractAddress: string, + args?: Record + ) => + [ + { + ...oraichainCw20StakingQueryKeys.address(chainId, contractAddress)[0], + method: 'total_staked_at_height', + args, + }, + ] as const, +} +export const oraichainCw20StakingQueries = { + config: ({ + chainId, + contractAddress, + options, + }: OraichainCw20StakingConfigQuery): UseQueryOptions< + ConfigResponse, + Error, + TData + > => ({ + queryKey: oraichainCw20StakingQueryKeys.config(chainId, contractAddress), + queryFn: async () => { + return new OraichainCw20StakingQueryClient( + await getCosmWasmClientForChainId(chainId), + contractAddress + ).config() + }, + ...options, + }), + poolInfo: ({ + chainId, + contractAddress, + args, + options, + }: OraichainCw20StakingPoolInfoQuery): UseQueryOptions< + PoolInfoResponse, + Error, + TData + > => ({ + queryKey: oraichainCw20StakingQueryKeys.poolInfo( + chainId, + contractAddress, + args + ), + queryFn: async () => { + return new OraichainCw20StakingQueryClient( + await getCosmWasmClientForChainId(chainId), + contractAddress + ).poolInfo({ + stakingToken: args.stakingToken, + }) + }, + ...options, + }), + rewardsPerSec: ({ + chainId, + contractAddress, + args, + options, + }: OraichainCw20StakingRewardsPerSecQuery): UseQueryOptions< + RewardsPerSecResponse, + Error, + TData + > => ({ + queryKey: oraichainCw20StakingQueryKeys.rewardsPerSec( + chainId, + contractAddress, + args + ), + queryFn: async () => { + return new OraichainCw20StakingQueryClient( + await getCosmWasmClientForChainId(chainId), + contractAddress + ).rewardsPerSec({ + stakingToken: args.stakingToken, + }) + }, + ...options, + }), + rewardInfo: ({ + chainId, + contractAddress, + args, + options, + }: OraichainCw20StakingRewardInfoQuery): UseQueryOptions< + RewardInfoResponse, + Error, + TData + > => ({ + queryKey: oraichainCw20StakingQueryKeys.rewardInfo( + chainId, + contractAddress, + args + ), + queryFn: async () => { + return new OraichainCw20StakingQueryClient( + await getCosmWasmClientForChainId(chainId), + contractAddress + ).rewardInfo({ + stakerAddr: args.stakerAddr, + stakingToken: args.stakingToken, + }) + }, + ...options, + }), + rewardInfos: ({ + chainId, + contractAddress, + args, + options, + }: OraichainCw20StakingRewardInfosQuery): UseQueryOptions< + ArrayOfRewardInfoResponse, + Error, + TData + > => ({ + queryKey: oraichainCw20StakingQueryKeys.rewardInfos( + chainId, + contractAddress, + args + ), + queryFn: async () => { + return new OraichainCw20StakingQueryClient( + await getCosmWasmClientForChainId(chainId), + contractAddress + ).rewardInfos({ + limit: args.limit, + order: args.order, + stakingToken: args.stakingToken, + startAfter: args.startAfter, + }) + }, + ...options, + }), + getPoolsInformation: ({ + chainId, + contractAddress, + options, + }: OraichainCw20StakingGetPoolsInformationQuery): UseQueryOptions< + ArrayOfQueryPoolInfoResponse, + Error, + TData + > => ({ + queryKey: oraichainCw20StakingQueryKeys.getPoolsInformation( + chainId, + contractAddress + ), + queryFn: async () => { + return new OraichainCw20StakingQueryClient( + await getCosmWasmClientForChainId(chainId), + contractAddress + ).getPoolsInformation() + }, + ...options, + }), + lockInfos: ({ + chainId, + contractAddress, + args, + options, + }: OraichainCw20StakingLockInfosQuery): UseQueryOptions< + LockInfosResponse, + Error, + TData + > => ({ + queryKey: oraichainCw20StakingQueryKeys.lockInfos( + chainId, + contractAddress, + args + ), + queryFn: async () => { + return new OraichainCw20StakingQueryClient( + await getCosmWasmClientForChainId(chainId), + contractAddress + ).lockInfos({ + limit: args.limit, + order: args.order, + stakerAddr: args.stakerAddr, + stakingToken: args.stakingToken, + startAfter: args.startAfter, + }) + }, + ...options, + }), + stakedBalanceAtHeight: ({ + chainId, + contractAddress, + args, + options, + }: OraichainCw20StakingStakedBalanceAtHeightQuery): UseQueryOptions< + StakedBalanceAtHeightResponse, + Error, + TData + > => ({ + queryKey: oraichainCw20StakingQueryKeys.stakedBalanceAtHeight( + chainId, + contractAddress, + args + ), + queryFn: async () => { + return new OraichainCw20StakingQueryClient( + await getCosmWasmClientForChainId(chainId), + contractAddress + ).stakedBalanceAtHeight({ + address: args.address, + assetKey: args.assetKey, + height: args.height, + }) + }, + ...options, + }), + totalStakedAtHeight: ({ + chainId, + contractAddress, + args, + options, + }: OraichainCw20StakingTotalStakedAtHeightQuery): UseQueryOptions< + TotalStakedAtHeightResponse, + Error, + TData + > => ({ + queryKey: oraichainCw20StakingQueryKeys.totalStakedAtHeight( + chainId, + contractAddress, + args + ), + queryFn: async () => { + return new OraichainCw20StakingQueryClient( + await getCosmWasmClientForChainId(chainId), + contractAddress + ).totalStakedAtHeight({ + assetKey: args.assetKey, + height: args.height, + }) + }, + ...options, + }), +} +export interface OraichainCw20StakingReactQuery { + chainId: string + contractAddress: string + options?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' | 'initialData' + > & { + initialData?: undefined + } +} +export interface OraichainCw20StakingTotalStakedAtHeightQuery + extends OraichainCw20StakingReactQuery { + args: { + assetKey: Addr + height?: number + } +} +export interface OraichainCw20StakingStakedBalanceAtHeightQuery + extends OraichainCw20StakingReactQuery { + args: { + address: string + assetKey: Addr + height?: number + } +} +export interface OraichainCw20StakingLockInfosQuery + extends OraichainCw20StakingReactQuery { + args: { + limit?: number + order?: number + stakerAddr: Addr + stakingToken: Addr + startAfter?: number + } +} +export interface OraichainCw20StakingGetPoolsInformationQuery + extends OraichainCw20StakingReactQuery {} +export interface OraichainCw20StakingRewardInfosQuery + extends OraichainCw20StakingReactQuery { + args: { + limit?: number + order?: number + stakingToken: Addr + startAfter?: Addr + } +} +export interface OraichainCw20StakingRewardInfoQuery + extends OraichainCw20StakingReactQuery { + args: { + stakerAddr: Addr + stakingToken?: Addr + } +} +export interface OraichainCw20StakingRewardsPerSecQuery + extends OraichainCw20StakingReactQuery { + args: { + stakingToken: Addr + } +} +export interface OraichainCw20StakingPoolInfoQuery + extends OraichainCw20StakingReactQuery { + args: { + stakingToken: Addr + } +} +export interface OraichainCw20StakingConfigQuery + extends OraichainCw20StakingReactQuery {} diff --git a/packages/state/query/queries/contracts/ValenceAccount.ts b/packages/state/query/queries/contracts/ValenceAccount.ts new file mode 100644 index 000000000..74f05583b --- /dev/null +++ b/packages/state/query/queries/contracts/ValenceAccount.ts @@ -0,0 +1,71 @@ +/** + * This file was automatically generated by @cosmwasm/ts-codegen@1.10.0. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run the @cosmwasm/ts-codegen generate command to regenerate this file. + */ + +import { UseQueryOptions } from '@tanstack/react-query' + +import { Addr } from '@dao-dao/types/contracts/ValenceAccount' +import { getCosmWasmClientForChainId } from '@dao-dao/utils' + +import { ValenceAccountQueryClient } from '../../../contracts/ValenceAccount' + +export const valenceAccountQueryKeys = { + contract: [ + { + contract: 'valenceAccount', + }, + ] as const, + address: (chainId: string, contractAddress: string) => + [ + { + ...valenceAccountQueryKeys.contract[0], + chainId, + address: contractAddress, + }, + ] as const, + getAdmin: ( + chainId: string, + contractAddress: string, + args?: Record + ) => + [ + { + ...valenceAccountQueryKeys.address(chainId, contractAddress)[0], + method: 'getAdmin', + args, + }, + ] as const, +} +export const valenceAccountQueries = { + getAdmin: ({ + chainId, + contractAddress, + options, + }: ValenceAccountGetAdminQuery): UseQueryOptions< + Addr, + Error, + TData + > => ({ + queryKey: valenceAccountQueryKeys.getAdmin(chainId, contractAddress), + queryFn: async () => + new ValenceAccountQueryClient( + await getCosmWasmClientForChainId(chainId), + contractAddress + ).getAdmin(), + ...options, + }), +} +export interface ValenceAccountReactQuery { + chainId: string + contractAddress: string + options?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' | 'initialData' + > & { + initialData?: undefined + } +} +export interface ValenceAccountGetAdminQuery + extends ValenceAccountReactQuery {} diff --git a/packages/state/query/queries/contracts/index.ts b/packages/state/query/queries/contracts/index.ts index ed31be1d5..7e1afe03e 100644 --- a/packages/state/query/queries/contracts/index.ts +++ b/packages/state/query/queries/contracts/index.ts @@ -1,6 +1,8 @@ export * from './Cw1Whitelist' export * from './Cw1Whitelist.extra' export * from './Cw20Base' +export * from './Cw20Stake' +export * from './Cw20Stake.extra' export * from './Cw3FlexMultisig' export * from './DaoDaoCore' export * from './PolytoneNote' @@ -23,6 +25,7 @@ export * from './DaoVotingCw20Staked' export * from './DaoVotingCw721Staked' export * from './DaoVotingCw721Staked.extra' export * from './DaoVotingTokenStaked' +export * from './DaoVotingTokenStaked.extra' export * from './DaoVotingNativeStaked' export * from './SecretDaoVotingCw4' export * from './SecretDaoVotingTokenStaked' @@ -42,3 +45,7 @@ export * from './CwPayrollFactory' export * from './CwPayrollFactory.extra' export * from './NeutronVault' export * from './NeutronVault.extra' +export * from './NeutronCwdSubdaoTimelockSingle' +export * from './ValenceAccount' +export * from './OraichainCw20Staking' +export * from './OraichainCw20Staking.extra' diff --git a/packages/state/query/queries/dao.ts b/packages/state/query/queries/dao.ts index f3d411e95..88bda8e8a 100644 --- a/packages/state/query/queries/dao.ts +++ b/packages/state/query/queries/dao.ts @@ -4,7 +4,11 @@ import { skipToken, } from '@tanstack/react-query' -import { AmountWithTimestamp, DaoSource } from '@dao-dao/types' +import { + AmountWithTimestamp, + ContractVersionInfo, + DaoSource, +} from '@dao-dao/types' import { SubDao, SubDaoWithChainId, @@ -13,6 +17,7 @@ import { } from '@dao-dao/types/contracts/DaoDaoCore' import { COMMUNITY_POOL_ADDRESS_PLACEHOLDER, + DAO_CORE_CONTRACT_NAMES, getSupportedChainConfig, isConfiguredChainName, } from '@dao-dao/utils' @@ -273,6 +278,44 @@ export const listWalletAdminOfDaos = async ( : [] } +/** + * List all potential SubDAOs of the DAO. + */ +export const listPotentialSubDaos = async ( + queryClient: QueryClient, + { + chainId, + address, + }: { + chainId: string + address: string + } +): Promise => { + const potentialSubDaos = await queryClient.fetchQuery( + indexerQueries.queryContract< + { + contractAddress: string + info: ContractVersionInfo + }[] + >(queryClient, { + chainId, + contractAddress: address, + formula: 'daoCore/potentialSubDaos', + noFallback: true, + }) + ) + + // Filter out those that do not appear to be DAO contracts and also the + // contract itself since it is probably its own admin. + return potentialSubDaos + .filter( + ({ contractAddress, info }) => + contractAddress !== address && + DAO_CORE_CONTRACT_NAMES.some((name) => info.contract.includes(name)) + ) + .map(({ contractAddress }) => contractAddress) +} + export const daoQueries = { /** * Fetch featured DAOs. @@ -330,7 +373,41 @@ export const daoQueries = { queryClient: QueryClient, options: Parameters[1] ): FetchQueryOptions => ({ - queryKey: ['dao', 'walletAdminOfDaos', options], + queryKey: ['dao', 'listWalletAdminOfDaos', options], queryFn: () => listWalletAdminOfDaos(queryClient, options), }), + /** + * List all potential SubDAOs of the DAO. + */ + listPotentialSubDaos: ( + queryClient: QueryClient, + options: Parameters[1] + ): FetchQueryOptions => ({ + queryKey: ['dao', 'listPotentialSubDaos', options], + queryFn: () => listPotentialSubDaos(queryClient, options), + }), + /** + * List all potential approval DAOs. + */ + listPotentialApprovalDaos: ( + queryClient: QueryClient, + { + chainId, + address, + }: { + chainId: string + address: string + } + ) => + indexerQueries.queryContract< + { + dao: string + preProposeAddress: string + }[] + >(queryClient, { + chainId, + contractAddress: address, + formula: 'daoCore/approvalDaos', + noFallback: true, + }), } diff --git a/packages/state/query/queries/index.ts b/packages/state/query/queries/index.ts index b940a37a8..7d4734439 100644 --- a/packages/state/query/queries/index.ts +++ b/packages/state/query/queries/index.ts @@ -5,7 +5,9 @@ export * from './chain' export * from './contract' export * from './dao' export * from './indexer' +export * from './neutron' export * from './nft' +export * from './noble' export * from './omniflix' export * from './polytone' export * from './profile' diff --git a/packages/state/query/queries/neutron.ts b/packages/state/query/queries/neutron.ts new file mode 100644 index 000000000..ea39118d3 --- /dev/null +++ b/packages/state/query/queries/neutron.ts @@ -0,0 +1,65 @@ +import { QueryClient, queryOptions } from '@tanstack/react-query' +import uniq from 'lodash.uniq' + +import { ChainId, GenericTokenBalance, TokenType } from '@dao-dao/types' +import { Fee as NeutronFee } from '@dao-dao/types/protobuf/codegen/neutron/feerefunder/fee' +import { MAINNET, neutronProtoRpcClientRouter } from '@dao-dao/utils' + +import { tokenQueries } from './token' + +/** + * Fetch Neutron IBC transfer fee. + */ +export const fetchNeutronIbcTransferFee = async ( + queryClient: QueryClient +): Promise<{ + fee: NeutronFee + // Total fees summed together. + sum: GenericTokenBalance[] +} | null> => { + const neutronClient = await neutronProtoRpcClientRouter.connect( + MAINNET ? ChainId.NeutronMainnet : ChainId.NeutronTestnet + ) + const { params } = await neutronClient.feerefunder.params() + const fee = params?.minFee + if (fee) { + const fees = [...fee.ackFee, ...fee.recvFee, ...fee.timeoutFee] + const uniqueDenoms = uniq(fees.map((fee) => fee.denom)) + + const tokens = await Promise.all( + uniqueDenoms.map((denom) => + queryClient.fetchQuery( + tokenQueries.info(queryClient, { + type: TokenType.Native, + chainId: MAINNET ? ChainId.NeutronMainnet : ChainId.NeutronTestnet, + denomOrAddress: denom, + }) + ) + ) + ) + + return { + fee, + sum: uniqueDenoms.map((denom) => ({ + token: tokens.find((token) => token.denomOrAddress === denom)!, + balance: fees + .filter(({ denom: feeDenom }) => feeDenom === denom) + .reduce((acc, { amount }) => acc + BigInt(amount), 0n) + .toString(), + })), + } + } else { + return null + } +} + +export const neutronQueries = { + /** + * Fetch Neutron IBC transfer fee. + */ + ibcTransferFee: (queryClient: QueryClient) => + queryOptions({ + queryKey: ['neutron', 'ibcTransferFee'], + queryFn: () => fetchNeutronIbcTransferFee(queryClient), + }), +} diff --git a/packages/state/query/queries/noble.ts b/packages/state/query/queries/noble.ts new file mode 100644 index 000000000..5e07e969e --- /dev/null +++ b/packages/state/query/queries/noble.ts @@ -0,0 +1,28 @@ +import { queryOptions } from '@tanstack/react-query' + +import { ChainId } from '@dao-dao/types' +import { Params as NobleTariffParams } from '@dao-dao/types/protobuf/codegen/tariff/params' +import { nobleProtoRpcClientRouter } from '@dao-dao/utils' + +/** + * Fetch Noble IBC transfer fee. + */ +export const fetchNobleIbcTransferFee = + async (): Promise => { + const nobleClient = await nobleProtoRpcClientRouter.connect( + ChainId.NobleMainnet + ) + const { params } = await nobleClient.tariff.params() + return params || null + } + +export const nobleQueries = { + /** + * Fetch Noble IBC transfer fee. + */ + ibcTransferFee: () => + queryOptions({ + queryKey: ['noble', 'ibcTransferFee'], + queryFn: fetchNobleIbcTransferFee, + }), +} diff --git a/packages/state/query/queries/skip.ts b/packages/state/query/queries/skip.ts index bcc51ae4a..d7b169991 100644 --- a/packages/state/query/queries/skip.ts +++ b/packages/state/query/queries/skip.ts @@ -95,6 +95,17 @@ export const fetchSkipRecommendedAsset = async ( return asset } +/** + * Fetch whether or not pfm is enabled for a chain. + */ +export const fetchSkipChainPfmEnabled = async ( + queryClient: QueryClient, + { chainId }: { chainId: string } +): Promise => { + const chain = await fetchSkipChain(queryClient, { chainId }) + return chain?.ibc_capabilities?.cosmos_pfm ?? chain?.pfm_enabled ?? false +} + export const skipQueries = { /** * Fetch Skip chain. @@ -129,4 +140,15 @@ export const skipQueries = { queryKey: ['skip', 'recommendedAsset', options], queryFn: () => fetchSkipRecommendedAsset(queryClient, options), }), + /** + * Fetch whether or not pfm is enabled for a chain. + */ + chainPfmEnabled: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['skip', 'chainPfmEnabled', options], + queryFn: () => fetchSkipChainPfmEnabled(queryClient, options), + }), } diff --git a/packages/state/recoil/selectors/chain.ts b/packages/state/recoil/selectors/chain.ts index b8fb9e416..2ecc0fc11 100644 --- a/packages/state/recoil/selectors/chain.ts +++ b/packages/state/recoil/selectors/chain.ts @@ -2,27 +2,21 @@ import { parsePacketsFromTendermintEvents } from '@confio/relayer/build/lib/util import { CosmWasmClient } from '@cosmjs/cosmwasm-stargate' import { fromBase64, toHex } from '@cosmjs/encoding' import { Coin, IndexedTx, StargateClient } from '@cosmjs/stargate' -import uniq from 'lodash.uniq' import { noWait, selector, selectorFamily, waitForAll, - waitForAllSettled, waitForAny, } from 'recoil' import { AccountType, - AllGovParams, AmountWithTimestamp, ChainId, GenericTokenBalance, GenericTokenBalanceWithOwner, - GovProposalVersion, GovProposalWithDecodedContent, - ProposalV1, - ProposalV1Beta1, TokenType, Validator, ValidatorSlash, @@ -33,8 +27,6 @@ import { Metadata } from '@dao-dao/types/protobuf/codegen/cosmos/bank/v1beta1/ba import { DecCoin } from '@dao-dao/types/protobuf/codegen/cosmos/base/v1beta1/coin' import { ProposalStatus, - TallyResult, - Vote, WeightedVoteOption, } from '@dao-dao/types/protobuf/codegen/cosmos/gov/v1beta1/gov' import { @@ -42,8 +34,6 @@ import { Validator as RpcValidator, } from '@dao-dao/types/protobuf/codegen/cosmos/staking/v1beta1/staking' import { Packet } from '@dao-dao/types/protobuf/codegen/ibc/core/channel/v1/channel' -import { Fee as NeutronFee } from '@dao-dao/types/protobuf/codegen/neutron/feerefunder/fee' -import { Params as NobleTariffParams } from '@dao-dao/types/protobuf/codegen/tariff/params' import { MAINNET, SecretCosmWasmClient, @@ -53,7 +43,6 @@ import { cosmosSdkVersionIs47OrHigher, cosmosValidatorToValidator, cosmwasmProtoRpcClientRouter, - decodeGovProposal, getAllRpcResponse, getCosmWasmClientForChainId, getNativeTokenForChainId, @@ -67,7 +56,8 @@ import { stargateClientRouter, } from '@dao-dao/utils' -import { SearchGovProposalsOptions } from '../../indexer' +import { chainQueries } from '../../query' +import { queryClientAtom } from '../atoms' import { refreshBlockHeightAtom, refreshGovProposalsAtom, @@ -78,7 +68,6 @@ import { import { queryGenericIndexerSelector, queryValidatorIndexerSelector, - searchGovProposalsSelector, } from './indexer' import { genericTokenSelector } from './token' import { walletTokenDaoStakedDenomsSelector } from './wallet' @@ -387,76 +376,6 @@ export const tokenFactoryDenomCreationFeeSelector = selectorFamily< }, }) -export const nobleTariffTransferFeeSelector = selector< - NobleTariffParams | undefined ->({ - key: 'nobleTariffTransferFee', - get: async ({ get }) => { - const nobleClient = get(nobleRpcClientSelector) - try { - const { params } = await nobleClient.tariff.params() - return params - } catch (err) { - console.error(err) - } - }, -}) - -export const neutronIbcTransferFeeSelector = selector< - | { - fee: NeutronFee - // Total fees summed together. - sum: GenericTokenBalance[] - } - | undefined ->({ - key: 'neutronIbcTransferFee', - get: async ({ get }) => { - const neutronClient = get(neutronRpcClientSelector) - try { - const { params } = await neutronClient.feerefunder.params() - const fee = params?.minFee - if (fee) { - const fees = [...fee.ackFee, ...fee.recvFee, ...fee.timeoutFee] - const uniqueDenoms = uniq(fees.map((fee) => fee.denom)) - const tokens = get( - waitForAll( - uniqueDenoms.map((denom) => - genericTokenSelector({ - type: TokenType.Native, - chainId: MAINNET - ? ChainId.NeutronMainnet - : ChainId.NeutronTestnet, - denomOrAddress: denom, - }) - ) - ) - ) - - return { - fee, - sum: uniqueDenoms.map((denom) => ({ - token: tokens.find((token) => token.denomOrAddress === denom)!, - balance: fees - .filter(({ denom: feeDenom }) => feeDenom === denom) - .reduce((acc, { amount }) => acc + BigInt(amount), 0n) - .toString(), - })), - } - } - } catch (err) { - if (err instanceof Error) { - console.error(err) - return - } - - // Rethrow non errors (like promises) since `get` throws a Promise while - // the data is still loading. - throw err - } - }, -}) - export const nativeDenomBalanceSelector = selectorFamily< Coin, WithChainId<{ walletAddress: string; denom: string }> @@ -611,60 +530,9 @@ export const nativeUnstakingDurationSecondsSelector = selectorFamily< }, }) -/** - * Search gov proposals in the indexer and decode their content. - */ -export const searchedDecodedGovProposalsSelector = selectorFamily< - { - proposals: GovProposalWithDecodedContent[] - total: number - }, - SearchGovProposalsOptions ->({ - key: 'searchedDecodedGovProposals', - get: - (options) => - async ({ get }) => { - const supportsV1Gov = get( - chainSupportsV1GovModuleSelector({ chainId: options.chainId }) - ) - - const { results, total } = get(searchGovProposalsSelector(options)) - - const proposals = ( - await Promise.allSettled( - results.map(async ({ value: { id, data } }) => - decodeGovProposal( - options.chainId, - supportsV1Gov - ? { - version: GovProposalVersion.V1, - id: BigInt(id), - proposal: ProposalV1.decode(fromBase64(data)), - } - : { - version: GovProposalVersion.V1_BETA_1, - id: BigInt(id), - proposal: ProposalV1Beta1.decode( - fromBase64(data), - undefined, - true - ), - } - ) - ) - ) - ).flatMap((p) => (p.status === 'fulfilled' ? p.value : [])) - - return { - proposals, - total, - } - }, -}) - -// Queries the chain for governance proposals, defaulting to those that are -// currently open for voting. +// Keeping this around for now (even though query exists for it now) because +// it's used in the open proposals feed selector and allows partial loading. +// Once we convert the open proposals feed to a query, we can remove this. export const govProposalsSelector = selectorFamily< { proposals: GovProposalWithDecodedContent[] @@ -678,492 +546,41 @@ export const govProposalsSelector = selectorFamily< >({ key: 'govProposals', get: - ({ chainId, status, offset, limit }) => - async ({ get }) => { - get(refreshGovProposalsAtom(chainId)) - if ( - status === ProposalStatus.PROPOSAL_STATUS_DEPOSIT_PERIOD || - status === ProposalStatus.PROPOSAL_STATUS_VOTING_PERIOD - ) { - get(refreshOpenProposalsAtom) - } - - // Try to load from indexer first. - const indexerProposals = get( - waitForAllSettled([ - searchedDecodedGovProposalsSelector({ - chainId, - status, - offset, - limit, - }), - ]) - )[0].valueMaybe() - - if (indexerProposals?.proposals.length) { - return indexerProposals - } - - // Fallback to querying chain if indexer failed. - const supportsV1Gov = get(chainSupportsV1GovModuleSelector({ chainId })) - - let v1Proposals: ProposalV1[] | undefined - let v1Beta1Proposals: ProposalV1Beta1[] | undefined - let total = 0 - - const client = get(cosmosRpcClientForChainSelector(chainId)) - if (supportsV1Gov) { - try { - if (limit === undefined && offset === undefined) { - v1Proposals = - (await getAllRpcResponse( - client.gov.v1.proposals, - { - proposalStatus: - status || ProposalStatus.PROPOSAL_STATUS_UNSPECIFIED, - voter: '', - depositor: '', - pagination: undefined, - }, - 'proposals', - true - )) || [] - total = v1Proposals.length - } else { - const response = await client.gov.v1.proposals({ - proposalStatus: - status || ProposalStatus.PROPOSAL_STATUS_UNSPECIFIED, - voter: '', - depositor: '', - pagination: { - key: new Uint8Array(), - offset: BigInt(offset || 0), - limit: BigInt(limit || 0), - countTotal: true, - reverse: true, - }, - }) - v1Proposals = response.proposals - total = Number(response.pagination?.total || 0) - } - } catch (err) { - // Fallback to v1beta1 query if v1 not supported. - if ( - !(err instanceof Error) || - !err.message.includes('unknown query path') - ) { - // Rethrow other errors. - throw err - } - } - } - - if (!v1Proposals) { - if (limit === undefined && offset === undefined) { - v1Beta1Proposals = - (await getAllRpcResponse( - client.gov.v1beta1.proposals, - { - proposalStatus: - status || ProposalStatus.PROPOSAL_STATUS_UNSPECIFIED, - voter: '', - depositor: '', - pagination: undefined, - }, - 'proposals', - true, - true - )) || [] - total = v1Beta1Proposals.length - } else { - const response = await client.gov.v1beta1.proposals( - { - proposalStatus: - status || ProposalStatus.PROPOSAL_STATUS_UNSPECIFIED, - voter: '', - depositor: '', - pagination: { - key: new Uint8Array(), - offset: BigInt(offset || 0), - limit: BigInt(limit || 0), - countTotal: true, - reverse: true, - }, - }, - true - ) - v1Beta1Proposals = response.proposals - total = Number(response.pagination?.total || 0) - } - } - - const proposals = await Promise.all([ - ...(v1Beta1Proposals || []).map((proposal) => - decodeGovProposal(chainId, { - version: GovProposalVersion.V1_BETA_1, - id: proposal.proposalId, - proposal, - }) - ), - ...(v1Proposals || []).map((proposal) => - decodeGovProposal(chainId, { - version: GovProposalVersion.V1, - id: proposal.id, - proposal, - }) - ), - ]) - - return { - proposals, - total, - } - }, -}) - -// Queries the chain for a specific governance proposal. -export const govProposalSelector = selectorFamily< - GovProposalWithDecodedContent, - WithChainId<{ proposalId: number }> ->({ - key: 'govProposal', - get: - ({ proposalId, chainId }) => + (options) => async ({ get }) => { - const id = get(refreshGovProposalsAtom(chainId)) - - const supportsV1Gov = get(chainSupportsV1GovModuleSelector({ chainId })) - - // Try to load from indexer first. - const indexerProposal: - | { - id: string - version: string - data: string - } - | undefined - | null = get( - waitForAllSettled([ - queryGenericIndexerSelector({ - chainId, - formula: 'gov/proposal', - args: { - id: proposalId, - }, - id, - }), - ]) - )[0].valueMaybe() - - let govProposal: GovProposalWithDecodedContent | undefined - - if (indexerProposal) { - if (supportsV1Gov) { - govProposal = await decodeGovProposal(chainId, { - version: GovProposalVersion.V1, - id: BigInt(proposalId), - proposal: ProposalV1.decode(fromBase64(indexerProposal.data)), - }) - } else { - govProposal = await decodeGovProposal(chainId, { - version: GovProposalVersion.V1_BETA_1, - id: BigInt(proposalId), - proposal: ProposalV1Beta1.decode( - fromBase64(indexerProposal.data), - undefined, - true - ), - }) - } - } + get(refreshGovProposalsAtom(options.chainId)) - // Fallback to querying chain if indexer failed. - if (!govProposal) { - const client = get(cosmosRpcClientForChainSelector(chainId)) - - if (supportsV1Gov) { - try { - const proposal = ( - await client.gov.v1.proposal({ - proposalId: BigInt(proposalId), - }) - ).proposal - if (!proposal) { - throw new Error('Proposal not found') - } - - govProposal = await decodeGovProposal(chainId, { - version: GovProposalVersion.V1, - id: BigInt(proposalId), - proposal, - }) - } catch (err) { - // Fallback to v1beta1 query if v1 not supported. - if ( - !(err instanceof Error) || - !err.message.includes('unknown query path') - ) { - // Rethrow other errors. - throw err - } - } - } - - if (!govProposal) { - const proposal = ( - await client.gov.v1beta1.proposal( - { - proposalId: BigInt(proposalId), - }, - true - ) - ).proposal - if (!proposal) { - throw new Error('Proposal not found') - } - - govProposal = await decodeGovProposal(chainId, { - version: GovProposalVersion.V1_BETA_1, - id: BigInt(proposalId), - proposal, - }) - } - } - - // If gov proposal is in deposit or voting period, refresh when open - // proposals refresh since it may have just opened (for voting) or closed. if ( - govProposal.proposal.status === - ProposalStatus.PROPOSAL_STATUS_DEPOSIT_PERIOD || - govProposal.proposal.status === - ProposalStatus.PROPOSAL_STATUS_VOTING_PERIOD + options.status === ProposalStatus.PROPOSAL_STATUS_DEPOSIT_PERIOD || + options.status === ProposalStatus.PROPOSAL_STATUS_VOTING_PERIOD ) { get(refreshOpenProposalsAtom) } - return govProposal + const queryClient = get(queryClientAtom) + return await queryClient.fetchQuery( + chainQueries.govProposals(queryClient, options) + ) }, }) -// Queries the chain for a vote on a governance proposal. +// Keeping this around for now (even though query exists for it now) because +// it's used in the open proposals feed selector and allows partial loading. +// Once we convert the open proposals feed to a query, we can remove this. export const govProposalVoteSelector = selectorFamily< WeightedVoteOption[], WithChainId<{ proposalId: number; voter: string }> >({ key: 'govProposalVote', get: - ({ proposalId, voter, chainId }) => - async ({ get }) => { - get(refreshGovProposalsAtom(chainId)) - - const client = get(cosmosRpcClientForChainSelector(chainId)) - - try { - return ( - ( - await client.gov.v1beta1.vote({ - proposalId: BigInt(proposalId), - voter, - }) - ).vote?.options || [] - ) - } catch (err) { - // If not found, the voter has not yet voted. - if ( - err instanceof Error && - err.message.includes('not found for proposal') - ) { - return [] - } - - throw err - } - }, -}) - -// Queries the chain for a vote on a governance proposal. -export const govProposalVotesSelector = selectorFamily< - { - votes: (Vote & { staked: bigint })[] - total: number - }, - WithChainId<{ - proposalId: number - offset: number - limit: number - }> ->({ - key: 'govProposalVotes', - get: - ({ proposalId, offset, limit, chainId }) => + (options) => async ({ get }) => { - get(refreshGovProposalsAtom(chainId)) + get(refreshGovProposalsAtom(options.chainId)) - const client = get(cosmosRpcClientForChainSelector(chainId)) - - const { votes, pagination } = await client.gov.v1beta1.votes({ - proposalId: BigInt(proposalId), - pagination: { - key: new Uint8Array(), - offset: BigInt(offset), - limit: BigInt(limit), - countTotal: true, - reverse: true, - }, - }) - const stakes = get( - waitForAll( - votes.map(({ voter }) => - nativeDelegatedBalanceSelector({ - chainId, - address: voter, - }) - ) - ) + const queryClient = get(queryClientAtom) + return await queryClient.fetchQuery( + chainQueries.govProposalVote(queryClient, options) ) - - return { - votes: votes.map((vote, index) => ({ - ...vote, - staked: BigInt(stakes[index].amount), - })), - total: Number(pagination?.total ?? 0), - } - }, -}) - -export const govProposalTallySelector = selectorFamily< - TallyResult | undefined, - WithChainId<{ proposalId: number }> ->({ - key: 'govProposalTally', - get: - ({ proposalId, chainId }) => - async ({ get }) => { - get(refreshGovProposalsAtom(chainId)) - - const client = get(cosmosRpcClientForChainSelector(chainId)) - - const tally = ( - await client.gov.v1beta1.tallyResult({ - proposalId: BigInt(proposalId), - }) - )?.tally - - return tally - }, -}) - -// Queries the chain for the governance module params. -export const govParamsSelector = selectorFamily>({ - key: 'govParams', - get: - ({ chainId }) => - async ({ get }) => { - const client = get(cosmosRpcClientForChainSelector(chainId)) - const supportsUnifiedV1GovParams = get( - chainSupportsV1GovModuleSelector({ chainId, require47: true }) - ) - - if (supportsUnifiedV1GovParams) { - try { - const { params } = await client.gov.v1.params({ - // Does not matter. - paramsType: 'tallying', - }) - if (!params) { - throw new Error('Gov params failed to load') - } - - return { - ...params, - quorum: Number(params.quorum), - threshold: Number(params.threshold), - vetoThreshold: Number(params.vetoThreshold), - minInitialDepositRatio: Number(params.minInitialDepositRatio), - } - } catch (err) { - // Fallback to v1beta1 query if v1 not supported. - if ( - !(err instanceof Error) || - !err.message.includes('unknown query path') - ) { - // Rethrow other errors. - throw err - } - } - } - - 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', - }), - ]) - - if (!votingParams || !depositParams || !tallyParams) { - throw new Error('Gov params failed to load') - } - - return { - minDeposit: depositParams.minDeposit, - maxDepositPeriod: depositParams.maxDepositPeriod, - votingPeriod: votingParams.votingPeriod, - quorum: Number(tallyParams.quorum), - threshold: Number(tallyParams.threshold), - vetoThreshold: Number(tallyParams.vetoThreshold), - // Cannot retrieve this from v1beta1 query, so just assume 0.25 as it is - // a conservative estimate. Osmosis uses 0.25 and Juno uses 0.2 as of - // 2023-08-13 - minInitialDepositRatio: 0.25, - } - }, -}) - -// Get module address. -export const moduleAddressSelector = selectorFamily< - string, - WithChainId<{ name: string }> ->({ - key: 'moduleAddress', - get: - ({ name, chainId }) => - async ({ get }) => { - const client = get(cosmosRpcClientForChainSelector(chainId)) - let account: ModuleAccount | undefined - try { - const response = await client.auth.v1beta1.moduleAccountByName({ - name, - }) - account = response?.account - } catch (err) { - // Some chains don't support getting a module account by name - // directly, so fallback to getting all module accounts. - if ( - err instanceof Error && - err.message.includes('unknown query path') - ) { - const { accounts } = await client.auth.v1beta1.moduleAccounts({}) - account = accounts.find( - (acc) => - 'name' in acc && (acc as unknown as ModuleAccount).name === name - ) as ModuleAccount | undefined - } else { - // Rethrow other errors. - throw err - } - } - - if (!account) { - throw new Error(`Failed to find ${name} module address.`) - } - return 'baseAccount' in account ? account.baseAccount?.address ?? '' : '' }, }) @@ -1265,20 +682,20 @@ export const communityPoolBalancesSelector = selectorFamily< // In case any chain has a community pool but no gov module, handle // gracefully by falling back to chain ID. I doubt this will ever happen, // but why not be safe... Cosmos is crazy. - const owner = - get( - waitForAllSettled([ - moduleAddressSelector({ - name: 'gov', - chainId, - }), - ]) - )[0].valueMaybe() || chainId + const queryClient = get(queryClientAtom) + const owner = await queryClient + .fetchQuery( + chainQueries.moduleAddress({ + chainId, + name: 'gov', + }) + ) + .catch(() => chainId) const balances = tokens.map( (token, i): GenericTokenBalanceWithOwner => ({ owner: { - type: AccountType.Native, + type: AccountType.Base, chainId, address: owner, }, diff --git a/packages/state/recoil/selectors/contract.ts b/packages/state/recoil/selectors/contract.ts index 0f5a8ca91..e860ae895 100644 --- a/packages/state/recoil/selectors/contract.ts +++ b/packages/state/recoil/selectors/contract.ts @@ -6,8 +6,8 @@ import { ContractVersion, InfoResponse, WithChainId } from '@dao-dao/types' import { ContractName, DAO_CORE_CONTRACT_NAMES, - INVALID_CONTRACT_ERROR_SUBSTRINGS, getChainForChainId, + isInvalidContractError, isSecretNetwork, isValidBech32Address, parseContractVersion, @@ -205,12 +205,7 @@ export const isContractSelector = selectorFamily< ? contract.includes(nameOrNames.name) : nameOrNames.names.some((name) => contract.includes(name)) } catch (err) { - if ( - err instanceof Error && - INVALID_CONTRACT_ERROR_SUBSTRINGS.some((substring) => - (err as Error).message.includes(substring) - ) - ) { + if (isInvalidContractError(err)) { console.error(err) return false } diff --git a/packages/state/recoil/selectors/contracts/Cw20Stake.ts b/packages/state/recoil/selectors/contracts/Cw20Stake.ts index 84290dba2..179bea179 100644 --- a/packages/state/recoil/selectors/contracts/Cw20Stake.ts +++ b/packages/state/recoil/selectors/contracts/Cw20Stake.ts @@ -4,7 +4,7 @@ import { WithChainId } from '@dao-dao/types' import { Claim, ClaimsResponse, - GetConfigResponse, + Config, GetHooksResponse, ListStakersResponse, StakedBalanceAtHeightResponse, @@ -243,7 +243,7 @@ export const totalValueSelector = selectorFamily< }, }) export const getConfigSelector = selectorFamily< - GetConfigResponse, + Config, QueryClientParams & { params: Parameters } diff --git a/packages/state/recoil/selectors/contracts/DaoDaoCore.ts b/packages/state/recoil/selectors/contracts/DaoDaoCore.ts index 7fe7b5e9d..4ce508ff9 100644 --- a/packages/state/recoil/selectors/contracts/DaoDaoCore.ts +++ b/packages/state/recoil/selectors/contracts/DaoDaoCore.ts @@ -34,7 +34,8 @@ import { VotingPowerAtHeightResponse, } from '@dao-dao/types/contracts/DaoDaoCore' import { - CW721_WORKAROUND_ITEM_KEY_PREFIX, + CW20_ITEM_KEY_PREFIX, + CW721_ITEM_KEY_PREFIX, MAINNET, POLYTONE_CW20_ITEM_KEY_PREFIX, POLYTONE_CW721_ITEM_KEY_PREFIX, @@ -528,19 +529,27 @@ export const allNativeCw20TokenListSelector = selectorFamily< get: (queryClientParams) => async ({ get }) => { + // Load CW20s from storage items. + const storageItemContracts = get( + listAllItemsWithPrefixSelector({ + ...queryClientParams, + prefix: CW20_ITEM_KEY_PREFIX, + }) + ).map(([key]) => key) + const list = get( queryContractIndexerSelector({ ...queryClientParams, formula: 'daoCore/cw20List', }) ) - if (list) { - return list + if (list && Array.isArray(list)) { + return uniq([...storageItemContracts, ...list]) } // If indexer query fails, fallback to contract query. - const tokenList: ArrayOfAddr = [] + const tokenList: ArrayOfAddr = [...storageItemContracts] while (true) { const response = await get( _cw20TokenListSelector({ @@ -563,7 +572,7 @@ export const allNativeCw20TokenListSelector = selectorFamily< } } - return tokenList + return uniq(tokenList) }, }) @@ -856,11 +865,11 @@ export const allNativeCw721TokenListSelector = selectorFamily< get: ({ governanceCollectionAddress, ...queryClientParams }) => async ({ get }) => { - // Load workaround CW721s from storage items. - const workaroundContracts = get( + // Load CW721s from storage items. + const storageItemContracts = get( listAllItemsWithPrefixSelector({ ...queryClientParams, - prefix: CW721_WORKAROUND_ITEM_KEY_PREFIX, + prefix: CW721_ITEM_KEY_PREFIX, }) ).map(([key]) => key) @@ -872,7 +881,7 @@ export const allNativeCw721TokenListSelector = selectorFamily< ) if (list && Array.isArray(list)) { // Copy to new array so we can mutate it below. - list = [...workaroundContracts, ...list] + list = [...storageItemContracts, ...list] // Add governance collection to beginning of list if not present. if ( governanceCollectionAddress && @@ -886,7 +895,7 @@ export const allNativeCw721TokenListSelector = selectorFamily< // If indexer query fails, fallback to contract query. - const tokenList: ArrayOfAddr = [...workaroundContracts] + const tokenList: ArrayOfAddr = [...storageItemContracts] while (true) { const response = await get( _cw721TokenListSelector({ @@ -917,7 +926,7 @@ export const allNativeCw721TokenListSelector = selectorFamily< tokenList.splice(0, 0, governanceCollectionAddress) } - return tokenList + return uniq(tokenList) }, }) @@ -1015,7 +1024,7 @@ export const allCw721CollectionsSelector = selectorFamily< > = { [queryClientParams.chainId]: { owners: accounts - .filter((a) => a.type === AccountType.Native) + .filter((a) => a.type === AccountType.Base) .map((a) => a.address), collectionAddresses: nativeCw721TokenList, }, @@ -1312,24 +1321,3 @@ export const polytoneProxiesSelector = selectorFamily< ) }, }) - -export const approvalDaosSelector = selectorFamily< - { - dao: string - preProposeAddress: string - }[], - QueryClientParams ->({ - key: 'daoDaoCoreApprovalDaos', - get: - ({ chainId, contractAddress }) => - ({ get }) => - get( - queryContractIndexerSelector({ - chainId, - contractAddress, - formula: 'daoCore/approvalDaos', - noFallback: true, - }) - ), -}) diff --git a/packages/state/recoil/selectors/contracts/DaoProposal.common.ts b/packages/state/recoil/selectors/contracts/DaoProposal.common.ts deleted file mode 100644 index 6be73897b..000000000 --- a/packages/state/recoil/selectors/contracts/DaoProposal.common.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { selectorFamily } from 'recoil' - -import { - DaoProposalMultipleSelectors, - DaoProposalSingleV2Selectors, - contractInfoSelector, -} from '@dao-dao/state' -import { WithChainId } from '@dao-dao/types' -import { ProposalResponse as DaoProposalMultipleProposalResponse } from '@dao-dao/types/contracts/DaoProposalMultiple' -import { ProposalResponse as DaoProposalSingleProposalResponse } from '@dao-dao/types/contracts/DaoProposalSingle.v2' -import { - DAO_PROPOSAL_MULTIPLE_CONTRACT_NAMES, - DAO_PROPOSAL_SINGLE_CONTRACT_NAMES, -} from '@dao-dao/utils' - -type QueryClientParams = WithChainId<{ - contractAddress: string -}> - -export const daoSelector = selectorFamily({ - key: 'daoProposalCommonDao', - get: - (queryClientParams) => - async ({ get }) => { - const { info } = get(contractInfoSelector(queryClientParams)) - - if (DAO_PROPOSAL_SINGLE_CONTRACT_NAMES.includes(info.contract)) { - return get( - DaoProposalSingleV2Selectors.daoSelector({ - ...queryClientParams, - params: [], - }) - ) - } else if (DAO_PROPOSAL_MULTIPLE_CONTRACT_NAMES.includes(info.contract)) { - return get( - DaoProposalMultipleSelectors.daoSelector({ - ...queryClientParams, - params: [], - }) - ) - } - - throw new Error('Unrecognized proposal module contract') - }, -}) - -export const proposalSelector = selectorFamily< - DaoProposalSingleProposalResponse | DaoProposalMultipleProposalResponse, - QueryClientParams & { - params: [ - { - proposalId: number - } - ] - } ->({ - key: 'daoProposalCommonProposal', - get: - (queryClientParams) => - async ({ get }) => { - const { info } = get(contractInfoSelector(queryClientParams)) - - if (DAO_PROPOSAL_SINGLE_CONTRACT_NAMES.includes(info.contract)) { - return get( - DaoProposalSingleV2Selectors.proposalSelector(queryClientParams) - ) - } else if (DAO_PROPOSAL_MULTIPLE_CONTRACT_NAMES.includes(info.contract)) { - return get( - DaoProposalMultipleSelectors.proposalSelector(queryClientParams) - ) - } - - throw new Error('Unrecognized proposal module contract') - }, -}) diff --git a/packages/state/recoil/selectors/contracts/index.ts b/packages/state/recoil/selectors/contracts/index.ts index 854f1f373..9f869963b 100644 --- a/packages/state/recoil/selectors/contracts/index.ts +++ b/packages/state/recoil/selectors/contracts/index.ts @@ -14,7 +14,6 @@ export * as DaoPreProposeApprovalSingleSelectors from './DaoPreProposeApprovalSi export * as DaoPreProposeApproverSelectors from './DaoPreProposeApprover' export * as DaoPreProposeMultipleSelectors from './DaoPreProposeMultiple' export * as DaoPreProposeSingleSelectors from './DaoPreProposeSingle' -export * as DaoProposalCommonSelectors from './DaoProposal.common' export * as DaoProposalMultipleSelectors from './DaoProposalMultiple' export * as DaoProposalSingleCommonSelectors from './DaoProposalSingle.common' export * as DaoProposalSingleV2Selectors from './DaoProposalSingle.v2' diff --git a/packages/state/recoil/selectors/dao.ts b/packages/state/recoil/selectors/dao.ts index ca6ec91b8..73ebfcba3 100644 --- a/packages/state/recoil/selectors/dao.ts +++ b/packages/state/recoil/selectors/dao.ts @@ -2,14 +2,12 @@ import { RecoilValueReadOnly, selectorFamily, waitForAll } from 'recoil' import { ContractVersion, - ContractVersionInfo, DaoDropdownInfo, Feature, LazyDaoCardProps, WithChainId, } from '@dao-dao/types' import { - DAO_CORE_CONTRACT_NAMES, INACTIVE_DAO_NAMES, VETOABLE_DAOS_ITEM_KEY_PREFIX, getChainGovernanceDaoDescription, @@ -26,7 +24,6 @@ import { daoQueries } from '../../query' import { queryClientAtom } from '../atoms' import { contractInfoSelector, contractVersionSelector } from './contract' import { DaoDaoCoreSelectors } from './contracts' -import { queryContractIndexerSelector } from './indexer' export const lazyDaoCardPropsSelector = selectorFamily< LazyDaoCardProps, @@ -216,40 +213,3 @@ export const daoVetoableDaosSelector = selectorFamily< } }), }) - -/** - * Retrieve all potential SubDAOs of the DAO from the indexer. - */ -export const daoPotentialSubDaosSelector = selectorFamily< - string[], - WithChainId<{ - coreAddress: string - }> ->({ - key: 'daoPotentialSubDaos', - get: - ({ coreAddress, chainId }) => - ({ get }) => { - const potentialSubDaos: { - contractAddress: string - info: ContractVersionInfo - }[] = get( - queryContractIndexerSelector({ - chainId, - contractAddress: coreAddress, - formula: 'daoCore/potentialSubDaos', - noFallback: true, - }) - ) - - // Filter out those that do not appear to be DAO contracts and also the - // contract itself since it is probably its own admin. - return potentialSubDaos - .filter( - ({ contractAddress, info }) => - contractAddress !== coreAddress && - DAO_CORE_CONTRACT_NAMES.some((name) => info.contract.includes(name)) - ) - .map(({ contractAddress }) => contractAddress) - }, -}) diff --git a/packages/state/recoil/selectors/discord.ts b/packages/state/recoil/selectors/discord.ts index 645bd13d1..1c31078d6 100644 --- a/packages/state/recoil/selectors/discord.ts +++ b/packages/state/recoil/selectors/discord.ts @@ -1,5 +1,6 @@ import { atomFamily, selectorFamily } from 'recoil' +import { DiscordNotifierRegistration } from '@dao-dao/types' import { DISCORD_NOTIFIER_API_BASE } from '@dao-dao/utils' type DiscordNotifierRegistrationsOptions = { @@ -16,19 +17,6 @@ export const refreshDiscordNotifierRegistrationsAtom = atomFamily< default: 0, }) -export type DiscordNotifierRegistration = { - id: string - guild: { - id: string - name: string - iconHash: string - } - channel: { - id: string - name: string - } -} - export const discordNotifierRegistrationsSelector = selectorFamily< DiscordNotifierRegistration[], DiscordNotifierRegistrationsOptions diff --git a/packages/state/recoil/selectors/ica.ts b/packages/state/recoil/selectors/ica.ts index b6f1f5364..557f40c0d 100644 --- a/packages/state/recoil/selectors/ica.ts +++ b/packages/state/recoil/selectors/ica.ts @@ -82,27 +82,3 @@ export const chainSupportsIcaHostSelector = selectorFamily< } }, }) - -/** - * Whether or not the chain has ICA controller setup. - */ -export const chainSupportsIcaControllerSelector = selectorFamily< - boolean, - WithChainId<{}> ->({ - key: 'chainSupportsIcaController', - get: - ({ chainId }) => - async ({ get }) => { - const ibc = get(ibcRpcClientForChainSelector(chainId)) - - try { - const { params: { controllerEnabled } = {} } = - await ibc.applications.interchain_accounts.controller.v1.params() - - return !!controllerEnabled - } catch { - return false - } - }, -}) diff --git a/packages/state/recoil/selectors/indexer.ts b/packages/state/recoil/selectors/indexer.ts index 2de69a5b5..6dace6894 100644 --- a/packages/state/recoil/selectors/indexer.ts +++ b/packages/state/recoil/selectors/indexer.ts @@ -7,7 +7,6 @@ import { IndexerUpStatus, WithChainId, } from '@dao-dao/types' -import { ProposalStatus } from '@dao-dao/types/protobuf/codegen/cosmos/gov/v1/gov' import { CommonError, WEB_SOCKET_PUSHER_APP_KEY, @@ -19,22 +18,18 @@ import { import { DaoProposalSearchResult, DaoSearchResult, - GovProposalSearchResult, QueryIndexerOptions, QuerySnapperOptions, SearchDaoProposalsOptions, SearchDaosOptions, - SearchGovProposalsOptions, getRecentDaoProposals, loadMeilisearchClient, queryIndexer, queryIndexerUpStatus, querySnapper, searchDaos, - searchGovProposals, } from '../../indexer' import { - refreshGovProposalsAtom, refreshIndexerUpStatusAtom, refreshOpenProposalsAtom, refreshWalletProposalStatsAtom, @@ -224,29 +219,6 @@ export const searchDaosSelector = selectorFamily< get: (options) => async () => await searchDaos(options), }) -export const searchGovProposalsSelector = selectorFamily< - { - results: GovProposalSearchResult[] - total: number - }, - SearchGovProposalsOptions ->({ - key: 'searchGovProposals', - get: - (options) => - async ({ get }) => { - get(refreshGovProposalsAtom(options.chainId)) - if ( - options.status === ProposalStatus.PROPOSAL_STATUS_DEPOSIT_PERIOD || - options.status === ProposalStatus.PROPOSAL_STATUS_VOTING_PERIOD - ) { - get(refreshOpenProposalsAtom) - } - - return await searchGovProposals(options) - }, -}) - /** * Get recent DAO proposals for a chain. */ diff --git a/packages/state/recoil/selectors/skip.ts b/packages/state/recoil/selectors/skip.ts index ec81611bd..519691041 100644 --- a/packages/state/recoil/selectors/skip.ts +++ b/packages/state/recoil/selectors/skip.ts @@ -1,4 +1,4 @@ -import { selectorFamily, waitForAll } from 'recoil' +import { selectorFamily } from 'recoil' import { GenericToken, @@ -51,34 +51,6 @@ export const skipAssetSelector = selectorFamily< ), }) -export const skipChainPfmEnabledSelector = selectorFamily({ - key: 'skipChainPfmEnabled', - get: - (chainId) => - ({ get }) => { - const chain = get(skipChainSelector(chainId)) - return chain?.ibc_capabilities?.cosmos_pfm ?? chain?.pfm_enabled ?? false - }, -}) - -export const skipAllChainsPfmEnabledSelector = selectorFamily< - boolean, - string[] ->({ - key: 'skipAllChainsPfmEnabled', - get: - (chainIds) => - ({ get }) => { - const chainsPfmEnabled = get( - waitForAll( - chainIds.map((chainId) => skipChainPfmEnabledSelector(chainId)) - ) - ) - - return chainsPfmEnabled.every(Boolean) - }, -}) - export const skipRecommendedAssetsSelector = selectorFamily< SkipAssetRecommendation[], { diff --git a/packages/state/recoil/selectors/treasury.ts b/packages/state/recoil/selectors/treasury.ts index bcd0c2073..4c24afce5 100644 --- a/packages/state/recoil/selectors/treasury.ts +++ b/packages/state/recoil/selectors/treasury.ts @@ -364,7 +364,7 @@ export const treasuryValueHistorySelector = selectorFamily< let allAccounts: Account[] = isCommunityPool ? [ { - type: AccountType.Native, + type: AccountType.Base, chainId: nativeChainId, address, }, diff --git a/packages/state/recoil/selectors/wallet.ts b/packages/state/recoil/selectors/wallet.ts index 79cef436e..16f46a1bb 100644 --- a/packages/state/recoil/selectors/wallet.ts +++ b/packages/state/recoil/selectors/wallet.ts @@ -427,7 +427,7 @@ export const walletTokenCardInfosSelector = selectorFamily< const info: TokenCardInfo = { owner: { - type: AccountType.Native, + type: AccountType.Base, chainId, address: walletAddress, }, diff --git a/packages/state/utils/index.ts b/packages/state/utils/index.ts index f10dc8ff8..2e54235ef 100644 --- a/packages/state/utils/index.ts +++ b/packages/state/utils/index.ts @@ -1,2 +1,3 @@ export * from './chain' export * from './DynamicGasPrice' +export * from './message' diff --git a/packages/state/utils/message.ts b/packages/state/utils/message.ts new file mode 100644 index 000000000..aff36b8db --- /dev/null +++ b/packages/state/utils/message.ts @@ -0,0 +1,200 @@ +import { AccountType, MessageProcessor } from '@dao-dao/types' +import { + decodeCw1WhitelistExecuteMsg, + decodeIcaExecuteMsg, + decodeMessage, + decodePolytoneExecuteMsg, +} from '@dao-dao/utils' + +import { accountQueries, polytoneNoteQueries } from '../query' + +/** + * Process a single Cosmos message, detecting the account that sent the message, + * and parsing wrapped executions (such as cross-chain messages, cw1-whitelist + * executions, etc.). + */ +export const processMessage: MessageProcessor = async ({ + chainId, + sender, + message, + queryClient, +}) => { + const accounts = await queryClient.fetchQuery( + accountQueries.list(queryClient, { + chainId, + address: sender, + }) + ) + + const decodedMessage = decodeMessage(message) + + // Check if Polytone wrapped execute. + const decodedPolytone = decodePolytoneExecuteMsg( + chainId, + decodedMessage, + 'any' + ) + if (decodedPolytone.match) { + let account = accounts.find( + (a) => + a.type === AccountType.Polytone && a.chainId === decodedPolytone.chainId + ) + // If not found for some reason, query for it. + if (!account) { + // Get proxy on destination chain. + const proxy = await queryClient.fetchQuery( + polytoneNoteQueries.remoteAddress(queryClient, { + chainId, + contractAddress: decodedPolytone.polytoneConnection.note, + args: { + localAddress: sender, + }, + }) + ) + + if (!proxy) { + throw new Error( + `No polytone proxy found on ${decodedPolytone.chainId} controlled by ${sender} on ${chainId}.` + ) + } + + account = { + type: AccountType.Polytone, + chainId: decodedPolytone.chainId, + address: proxy, + } + } + + return { + message, + account, + isCrossChain: true, + isWrapped: true, + wrappedMessages: await Promise.all( + decodedPolytone.cosmosMsgs.map(async (message) => + processMessage({ + chainId: account!.chainId, + sender: account!.address, + message, + queryClient, + }) + ) + ), + decodedMessage: + decodedPolytone.msgs.length === 0 ? null : decodedPolytone.msgs[0], + decodedMessages: decodedPolytone.msgs, + polytone: decodedPolytone, + } + } + + // Check if ICA wrapped execute. + const decodedIca = decodeIcaExecuteMsg(chainId, decodedMessage, 'any') + if (decodedIca.match) { + let account = accounts.find( + (a) => a.type === AccountType.Ica && a.chainId === decodedIca.chainId + ) + // If not found, query for it. + if (!account) { + // Get remote ICA on destination chain. + const remoteIcaAddress = await queryClient.fetchQuery( + accountQueries.remoteIcaAddress({ + srcChainId: chainId, + address: sender, + destChainId: decodedIca.chainId, + }) + ) + + if (!remoteIcaAddress) { + throw new Error( + `No ICA address found on ${decodedIca.chainId} controlled by ${sender} on ${chainId}.` + ) + } + + account = { + type: AccountType.Ica, + chainId: decodedIca.chainId, + address: remoteIcaAddress, + } + } + + return { + message, + account, + isCrossChain: true, + isWrapped: true, + wrappedMessages: await Promise.all( + decodedIca.cosmosMsgsWithSenders.map(async ({ msg }) => + processMessage({ + chainId: account!.chainId, + sender: account!.address, + message: msg, + queryClient, + }) + ) + ), + decodedMessage: + decodedIca.msgsWithSenders.length === 0 + ? null + : decodedIca.msgsWithSenders[0].msg, + decodedMessages: decodedIca.msgsWithSenders.map(({ msg }) => msg), + ica: decodedIca, + } + } + + // Check if cw1-whitelist wrapped execute. + const decodedCw1Whitelist = decodeCw1WhitelistExecuteMsg( + decodedMessage, + 'any' + ) + if (decodedCw1Whitelist) { + const account = + accounts.find( + (a) => + a.type === AccountType.Cw1Whitelist && + a.address === decodedCw1Whitelist.address + ) || + // If not found, fetch the account. + (await queryClient.fetchQuery( + accountQueries.cw1Whitelist(queryClient, { + chainId, + address: decodedCw1Whitelist.address, + }) + )) + + return { + message, + account, + isCrossChain: false, + isWrapped: true, + wrappedMessages: await Promise.all( + decodedCw1Whitelist.cosmosMsgs.map(async (message) => + processMessage({ + chainId: account!.chainId, + sender: account!.address, + message, + queryClient, + }) + ) + ), + decodedMessage: + decodedCw1Whitelist.msgs.length === 0 + ? null + : decodedCw1Whitelist.msgs[0].msg, + decodedMessages: decodedCw1Whitelist.msgs, + } + } + + return { + message, + account: { + type: AccountType.Base, + chainId, + address: sender, + }, + isCrossChain: false, + isWrapped: false, + wrappedMessages: [], + decodedMessage, + decodedMessages: [decodedMessage], + } +} diff --git a/packages/stateful/actions/context.ts b/packages/stateful/actions/context.ts new file mode 100644 index 000000000..1fecf1d4c --- /dev/null +++ b/packages/stateful/actions/context.ts @@ -0,0 +1,31 @@ +import { useMemo } from 'react' + +import { useActionOptions } from '@dao-dao/stateless' +import { ActionContextType, ActionEncodeContext } from '@dao-dao/types' + +import { useProposalModuleAdapterCommonContextIfAvailable } from '../proposal-module-adapter/react/context' + +/** + * Get encode context. If in a DAO, must be used inside a proposal module common + * context. + */ +export const useActionEncodeContext = (): ActionEncodeContext => { + const { context } = useActionOptions() + + const proposalModule = + useProposalModuleAdapterCommonContextIfAvailable()?.options.proposalModule + if (context.type === ActionContextType.Dao && !proposalModule) { + throw new Error('Proposal module not available in DAO context.') + } + + return useMemo( + (): ActionEncodeContext => + context.type === ActionContextType.Dao + ? { + ...context, + proposalModule: proposalModule!, + } + : context, + [context, proposalModule] + ) +} diff --git a/packages/stateful/actions/core/subdaos/AcceptSubDao/Component.stories.tsx b/packages/stateful/actions/core/actions/AcceptSubDao/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/subdaos/AcceptSubDao/Component.stories.tsx rename to packages/stateful/actions/core/actions/AcceptSubDao/Component.stories.tsx diff --git a/packages/stateful/actions/core/actions/AcceptSubDao/Component.tsx b/packages/stateful/actions/core/actions/AcceptSubDao/Component.tsx new file mode 100644 index 000000000..22fe59682 --- /dev/null +++ b/packages/stateful/actions/core/actions/AcceptSubDao/Component.tsx @@ -0,0 +1,43 @@ +import { ComponentType } from 'react' +import { useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' + +import { InputErrorMessage, InputLabel, useChain } from '@dao-dao/stateless' +import { AddressInputProps } from '@dao-dao/types' +import { ActionComponent } from '@dao-dao/types/actions' +import { makeValidateAddress, validateRequired } from '@dao-dao/utils' + +export type AcceptSubDaoData = { + chainId: string + address: string +} + +type AcceptSubDaoDataOptions = { + AddressInput: ComponentType> +} + +export const AcceptSubDaoComponent: ActionComponent< + AcceptSubDaoDataOptions, + AcceptSubDaoData +> = ({ fieldNamePrefix, errors, isCreating, options: { AddressInput } }) => { + const { t } = useTranslation() + const { bech32_prefix: bech32Prefix } = useChain() + const { register } = useFormContext() + + const addressFieldName = (fieldNamePrefix + 'address') as 'address' + + return ( +
+ + + +
+ ) +} diff --git a/packages/stateful/actions/core/subdaos/AcceptSubDao/README.md b/packages/stateful/actions/core/actions/AcceptSubDao/README.md similarity index 100% rename from packages/stateful/actions/core/subdaos/AcceptSubDao/README.md rename to packages/stateful/actions/core/actions/AcceptSubDao/README.md diff --git a/packages/stateful/actions/core/actions/AcceptSubDao/index.tsx b/packages/stateful/actions/core/actions/AcceptSubDao/index.tsx new file mode 100644 index 000000000..74efb5a6c --- /dev/null +++ b/packages/stateful/actions/core/actions/AcceptSubDao/index.tsx @@ -0,0 +1,339 @@ +import { useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' + +import { accountQueries, contractQueries } from '@dao-dao/state/query' +import { + ActionBase, + ChainProvider, + CheckEmoji, + DaoSupportedChainPickerInput, + useActionOptions, +} from '@dao-dao/stateless' +import { AccountType, UnifiedCosmosMsg } from '@dao-dao/types' +import { + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { + getAccountAddress, + getChainAddressForActionOptions, + getChainForChainId, + isValidBech32Address, + makeExecuteSmartContractMessage, + maybeMakePolytoneExecuteMessages, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { AddressInput } from '../../../../components' +import { AcceptSubDaoComponent, AcceptSubDaoData } from './Component' + +const Component: ActionComponent = (props) => { + const { t } = useTranslation() + const { context } = useActionOptions() + const { watch } = useFormContext() + const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') + + return ( + <> + {props.isCreating && ( +

{t('info.acceptSubDaoActionDescription')}

+ )} + + {context.type === ActionContextType.Dao && ( + + )} + + + + + + ) +} + +export class AcceptSubDaoAction extends ActionBase { + public readonly key = ActionKey.AcceptSubDao + public readonly Component = Component + + constructor(options: ActionOptions) { + super(options, { + Icon: CheckEmoji, + label: options.t('title.acceptSubDao'), + description: options.t('info.acceptSubDaoDescription'), + }) + + this.defaults = { + chainId: options.chain.chain_id, + address: '', + } + } + + async encode({ + chainId, + address, + }: AcceptSubDaoData): Promise { + const sender = getChainAddressForActionOptions(this.options, chainId) + if (!sender) { + throw new Error('No sender address found for chain') + } + + if ( + !isValidBech32Address(address, getChainForChainId(chainId).bech32_prefix) + ) { + throw new Error('Invalid SubDAO address') + } + + // Get the current admin of the subDAO and its accounts. + const [subDaoAdmin, subDaoAccounts] = await Promise.all([ + this.options.queryClient.fetchQuery( + contractQueries.admin({ + chainId, + address, + }) + ), + this.options.queryClient.fetchQuery( + accountQueries.list(this.options.queryClient, { + chainId, + address, + }) + ), + ]) + + // Get the SubDAO's address that exists on the same chain as us. This is + // either the core address if on the same chain, or a Polytone address if + // a cross-chain SubDAO. + const subDaoAddressOnOurChain = getAccountAddress({ + accounts: subDaoAccounts, + chainId: this.options.chain.chain_id, + types: [AccountType.Base, AccountType.Polytone], + }) + if (!subDaoAddressOnOurChain) { + throw new Error( + "SubDAO must either be on the same chain or have a cross-chain account on this DAO's chain" + ) + } + + return [ + ...maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + chainId, + [ + makeExecuteSmartContractMessage({ + chainId, + sender, + contractAddress: address, + msg: { + accept_admin_nomination: {}, + }, + }), + // Check if SubDAO is currently set to itself. If so, we can DAO admin + // execute to update the contract-level admin to us right after + // accepting the admin nomination. + ...(subDaoAdmin === address + ? [ + makeExecuteSmartContractMessage({ + chainId, + sender, + contractAddress: address, + msg: { + execute_admin_msgs: { + msgs: [ + { + wasm: { + update_admin: { + contract_addr: address, + admin: sender, + }, + }, + }, + ], + }, + }, + }), + ] + : []), + ] + ), + // If we're a DAO, add to our SubDAOs list. + ...(this.options.context.type === ActionContextType.Dao + ? [ + makeExecuteSmartContractMessage({ + chainId: this.options.chain.chain_id, + sender: this.options.address, + contractAddress: this.options.address, + msg: { + update_sub_daos: { + to_add: [{ addr: subDaoAddressOnOurChain }], + to_remove: [], + }, + }, + }), + ] + : []), + ] + } + + async match(messages: ProcessedMessage[]): Promise { + // First (potentially wrapped) message must be accept admin nomination. + if ( + !objectMatchesStructure(messages[0].decodedMessage, { + wasm: { + execute: { + msg: { + accept_admin_nomination: {}, + }, + }, + }, + }) + ) { + return false + } + + // Get SubDAO account on the same chain as we are. + const subDaoAccounts = await this.options.queryClient.fetchQuery( + accountQueries.list(this.options.queryClient, { + chainId: messages[0].account.chainId, + address: messages[0].decodedMessage.wasm.execute.contract_addr, + }) + ) + const subDaoAccountOnOurChain = getAccountAddress({ + accounts: subDaoAccounts, + chainId: this.options.chain.chain_id, + types: [AccountType.Base, AccountType.Polytone], + }) + + const isExecAdminUpdate = (decodedMessage: any) => + objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + msg: { + execute_admin_msgs: { + msgs: [ + { + wasm: { + update_admin: { + contract_addr: {}, + admin: {}, + }, + }, + }, + ], + }, + }, + }, + }, + }) && + // make sure the update admin action is updating the same contract + // that the accept admin nomination is for + decodedMessage.wasm.execute.msg.execute_admin_msgs.msgs[0].wasm + .update_admin.contract_addr === + messages[0].decodedMessage.wasm.execute.contract_addr && + // make sure the update admin action is updating the admin to the sender + // of the accept admin nomination + decodedMessage.wasm.execute.msg.execute_admin_msgs.msgs[0].wasm + .update_admin.admin === messages[0].account.address + + const isUpdateSubDaos = (decodedMessage: any) => + objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + msg: { + update_sub_daos: { + to_add: [{ addr: {} }], + to_remove: [], + }, + }, + }, + }, + }) && + // make sure the SubDAO being added is an account controlled by the SubDAO + // we're accepting the admin nomination for + decodedMessage.wasm.execute.msg.update_sub_daos.to_add[0].addr === + subDaoAccountOnOurChain + + if (this.options.context.type === ActionContextType.Dao) { + if (messages[0].isCrossChain) { + // For cross-chain SubDAOs, the two outer messages are: + // - Polytone execute containing one or two messages: + // - accept_admin_nomination + // - execute_admin_msgs (optional) + // - update_sub_daos + if ( + messages.length >= 2 && + (messages[0].decodedMessages.length === 1 || + (messages[0].decodedMessages.length === 2 && + isExecAdminUpdate(messages[0].decodedMessages[1]))) && + isUpdateSubDaos(messages[1].decodedMessage) + ) { + return 2 + } + } else { + // For same-chain SubDAOs, there are either two or three outer messages: + // - accept_admin_nomination + // - execute_admin_msgs (optional) + // - update_sub_daos + if ( + messages.length >= 3 && + isExecAdminUpdate(messages[1].decodedMessage) && + isUpdateSubDaos(messages[2].decodedMessage) + ) { + return 3 + } else if ( + messages.length >= 2 && + isUpdateSubDaos(messages[1].decodedMessage) + ) { + return 2 + } + } + } else { + if (messages[0].isCrossChain) { + // For cross-chain SubDAOs, the one outer message is: + // - Polytone execute containing one or two messages: + // - accept_admin_nomination + // - execute_admin_msgs (optional) + if ( + messages.length >= 1 && + (messages[0].decodedMessages.length === 1 || + (messages[0].decodedMessages.length === 2 && + isExecAdminUpdate(messages[0].decodedMessages[1]))) + ) { + return 2 + } + } else { + // For same-chain SubDAOs, there are either one or two outer messages: + // - accept_admin_nomination + // - execute_admin_msgs (optional) + return messages.length >= 2 && + isExecAdminUpdate(messages[1].decodedMessage) + ? 2 + : 1 + } + } + + return false + } + + decode([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): AcceptSubDaoData { + return { + chainId, + address: decodedMessage.wasm.execute.contract_addr, + } + } +} diff --git a/packages/stateful/actions/core/authorizations/AuthzExec/Component.tsx b/packages/stateful/actions/core/actions/AuthzExec/Component.tsx similarity index 100% rename from packages/stateful/actions/core/authorizations/AuthzExec/Component.tsx rename to packages/stateful/actions/core/actions/AuthzExec/Component.tsx diff --git a/packages/stateful/actions/core/authorizations/AuthzExec/README.md b/packages/stateful/actions/core/actions/AuthzExec/README.md similarity index 100% rename from packages/stateful/actions/core/authorizations/AuthzExec/README.md rename to packages/stateful/actions/core/actions/AuthzExec/README.md diff --git a/packages/stateful/actions/core/authorizations/AuthzExec/index.tsx b/packages/stateful/actions/core/actions/AuthzExec/index.tsx similarity index 55% rename from packages/stateful/actions/core/authorizations/AuthzExec/index.tsx rename to packages/stateful/actions/core/actions/AuthzExec/index.tsx index b3b7a485a..c829d08e4 100644 --- a/packages/stateful/actions/core/authorizations/AuthzExec/index.tsx +++ b/packages/stateful/actions/core/actions/AuthzExec/index.tsx @@ -1,36 +1,35 @@ import { useQueryClient } from '@tanstack/react-query' -import { useCallback, useMemo } from 'react' import { useFormContext } from 'react-hook-form' -import { contractQueries } from '@dao-dao/state/query' +import { contractQueries, processMessage } from '@dao-dao/state' import { + ActionBase, + ActionsContext, ChainProvider, DaoSupportedChainPickerInput, LockWithKeyEmoji, + useActionOptions, useChain, } from '@dao-dao/stateless' import { ActionComponent, ActionContextType, ActionKey, - ActionMaker, + ActionMatch, + ActionOptions, + ProcessedMessage, UnifiedCosmosMsg, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, cwMsgToProtobuf, makeStargateMessage, protobufToCwMsg, } from '@dao-dao/types' import { MsgExec } from '@dao-dao/types/protobuf/codegen/cosmos/authz/v1beta1/tx' import { - decodePolytoneExecuteMsg, getChainAddressForActionOptions, getChainForChainId, isDecodedStargateMsg, isValidBech32Address, - maybeMakePolytoneExecuteMessage, - objectMatchesStructure, + maybeMakePolytoneExecuteMessages, } from '@dao-dao/utils' import { @@ -40,12 +39,8 @@ import { SuspenseLoader, } from '../../../../components' import { useQueryLoadingData } from '../../../../hooks' -import { - WalletActionsProvider, - useActionOptions, - useActionsForMatching, - useLoadedActionsAndCategories, -} from '../../../react' +import { useActionEncodeContext } from '../../../context' +import { WalletActionsProvider } from '../../../providers/wallet' import { AuthzExecData, AuthzExecOptions, @@ -55,41 +50,41 @@ import { type InnerOptions = Pick const InnerComponentLoading: ActionComponent = (props) => ( - -) - -const InnerComponent: ActionComponent = (props) => { - const { categories, loadedActions } = useLoadedActionsAndCategories({ - isCreating: props.isCreating, - }) - const actionsForMatching = useActionsForMatching() - - return ( + > - ) -} + +) + +const InnerComponent: ActionComponent = (props) => ( + +) const InnerComponentWrapper: ActionComponent< InnerOptions & { address: string } @@ -112,7 +107,7 @@ const InnerComponentWrapper: ActionComponent< false ) - return isDao.loading ? ( + return isDao.loading || isDao.updating ? ( ) : isDao.data ? ( { ) } -export const makeAuthzExecAction: ActionMaker = (options) => { - const { - t, - chain: { chain_id: currentChainId }, - } = options +export class AuthzExecAction extends ActionBase { + public readonly key = ActionKey.AuthzExec + public readonly Component = Component - const useDefaults: UseDefaults = () => ({ - chainId: currentChainId, - address: '', - msgs: [], - }) + constructor(options: ActionOptions) { + super(options, { + Icon: LockWithKeyEmoji, + label: options.t('title.authzExec'), + description: options.t('info.authzExecDescription'), + }) - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - let chainId = currentChainId - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg + this.defaults = { + chainId: options.chain.chain_id, + address: '', + msgs: [], } + } - return useMemo(() => { - if ( - !isDecodedStargateMsg(msg) || - msg.stargate.typeUrl !== MsgExec.typeUrl || - !objectMatchesStructure(msg.stargate.value, { - grantee: {}, - msgs: {}, - }) || - !Array.isArray(msg.stargate.value.msgs) - ) { - return { match: false } - } - - const execMsg = msg.stargate.value as MsgExec - - // Group adjacent messages by sender, preserving message order. - const msgsPerSender = execMsg.msgs - .map((msg) => protobufToCwMsg(getChainForChainId(chainId), msg)) - .reduce( - (acc, { msg, sender }) => { - const last = acc[acc.length - 1] - if (last && last.sender === sender) { - last.msgs.push(msg) - } else { - acc.push({ sender, msgs: [msg] }) - } - return acc - }, - [] as { - sender: string - msgs: UnifiedCosmosMsg[] - }[] - ) - - return { - match: true, - data: { - chainId, - // Technically each message could have a different address. While we - // don't support that on creation, we can still detect and render them - // correctly in the component. - address: '', - msgs: [], - _msgs: msgsPerSender, + encode({ chainId, address, msgs }: AuthzExecData): UnifiedCosmosMsg[] { + return maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + chainId, + makeStargateMessage({ + stargate: { + typeUrl: MsgExec.typeUrl, + value: { + grantee: getChainAddressForActionOptions(this.options, chainId), + msgs: msgs.map((msg) => cwMsgToProtobuf(chainId, msg, address)), + } as MsgExec, }, - } - }, [chainId, msg]) + }) + ) } - const useTransformToCosmos: UseTransformToCosmos = () => - useCallback( - ({ chainId, address, msgs }) => - maybeMakePolytoneExecuteMessage( - currentChainId, - chainId, - makeStargateMessage({ - stargate: { - typeUrl: MsgExec.typeUrl, - value: { - grantee: getChainAddressForActionOptions(options, chainId), - msgs: msgs.map((msg) => cwMsgToProtobuf(chainId, msg, address)), - } as MsgExec, - }, - }) - ), - [] + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return ( + isDecodedStargateMsg(decodedMessage, MsgExec, { + grantee: {}, + msgs: {}, + }) && Array.isArray(decodedMessage.stargate.value.msgs) ) + } - return { - key: ActionKey.AuthzExec, - Icon: LockWithKeyEmoji, - label: t('title.authzExec'), - description: t('info.authzExecDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, + decode([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): AuthzExecData { + const execMsg = decodedMessage.stargate.value as MsgExec + + // Group adjacent messages by sender, preserving message order. + const msgsPerSender = execMsg.msgs + .map((msg) => protobufToCwMsg(getChainForChainId(chainId), msg)) + .reduce( + (acc, { msg, sender }) => { + const last = acc[acc.length - 1] + if (last && last.sender === sender) { + last.msgs.push(msg) + } else { + acc.push({ sender, msgs: [msg] }) + } + return acc + }, + [] as { + sender: string + msgs: UnifiedCosmosMsg[] + }[] + ) + + return { + chainId, + // Technically each message could have a different address. While we don't + // support that on creation, we can still detect and render them correctly + // in the component. + address: '', + msgs: [], + _msgs: msgsPerSender, + } } } diff --git a/packages/stateful/actions/core/authorizations/AuthzGrantRevoke/Component.stories.tsx b/packages/stateful/actions/core/actions/AuthzGrantRevoke/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/authorizations/AuthzGrantRevoke/Component.stories.tsx rename to packages/stateful/actions/core/actions/AuthzGrantRevoke/Component.stories.tsx diff --git a/packages/stateful/actions/core/authorizations/AuthzGrantRevoke/Component.tsx b/packages/stateful/actions/core/actions/AuthzGrantRevoke/Component.tsx similarity index 100% rename from packages/stateful/actions/core/authorizations/AuthzGrantRevoke/Component.tsx rename to packages/stateful/actions/core/actions/AuthzGrantRevoke/Component.tsx diff --git a/packages/stateful/actions/core/authorizations/AuthzGrantRevoke/README.md b/packages/stateful/actions/core/actions/AuthzGrantRevoke/README.md similarity index 100% rename from packages/stateful/actions/core/authorizations/AuthzGrantRevoke/README.md rename to packages/stateful/actions/core/actions/AuthzGrantRevoke/README.md diff --git a/packages/stateful/actions/core/actions/AuthzGrantRevoke/index.tsx b/packages/stateful/actions/core/actions/AuthzGrantRevoke/index.tsx new file mode 100644 index 000000000..17af40a01 --- /dev/null +++ b/packages/stateful/actions/core/actions/AuthzGrantRevoke/index.tsx @@ -0,0 +1,480 @@ +import { fromUtf8, toUtf8 } from '@cosmjs/encoding' +import JSON5 from 'json5' +import { useFormContext } from 'react-hook-form' + +import { tokenQueries } from '@dao-dao/state/query' +import { + ActionBase, + ChainProvider, + DaoSupportedChainPickerInput, + KeyEmoji, + Loader, + useActionOptions, +} from '@dao-dao/stateless' +import { + TokenType, + UnifiedCosmosMsg, + makeStargateMessage, +} from '@dao-dao/types' +import { + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { GenericAuthorization } from '@dao-dao/types/protobuf/codegen/cosmos/authz/v1beta1/authz' +import { + MsgGrant, + MsgRevoke, +} from '@dao-dao/types/protobuf/codegen/cosmos/authz/v1beta1/tx' +import { SendAuthorization } from '@dao-dao/types/protobuf/codegen/cosmos/bank/v1beta1/authz' +import { + AcceptedMessageKeysFilter, + AcceptedMessagesFilter, + CombinedLimit, + ContractExecutionAuthorization, + ContractGrant, + ContractMigrationAuthorization, + MaxCallsLimit, +} from '@dao-dao/types/protobuf/codegen/cosmwasm/wasm/v1/authz' +import { Any } from '@dao-dao/types/protobuf/codegen/google/protobuf/any' +import { + convertDenomToMicroDenomStringWithDecimals, + convertMicroDenomToDenomWithDecimals, + getChainAddressForActionOptions, + isDecodedStargateMsg, + maybeMakePolytoneExecuteMessages, +} from '@dao-dao/utils' + +import { AddressInput, SuspenseLoader } from '../../../../components' +import { useTokenBalances } from '../../../hooks' +import { AuthzGrantRevokeComponent as StatelessAuthzAuthorizationComponent } from './Component' +import { + ACTION_TYPES, + AUTHORIZATION_TYPES, + AuthzGrantRevokeData, + FILTER_TYPES, + LIMIT_TYPES, +} from './types' + +const Component: ActionComponent = (props) => { + const balances = useTokenBalances() + + const { context } = useActionOptions() + const { watch } = useFormContext() + const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') + + return ( + <> + {context.type === ActionContextType.Dao && ( + + )} + + + } + forceFallback={ + // Manually trigger loader. + balances.loading + } + > + + + + + ) +} + +export class AuthzGrantRevokeAction extends ActionBase { + public readonly key = ActionKey.AuthzGrantRevoke + public readonly Component = Component + + constructor(options: ActionOptions) { + super(options, { + Icon: KeyEmoji, + label: options.t('title.authzAuthorization'), + description: options.t('info.authzAuthorizationDescription'), + keywords: ['authorization', 'authz'], + }) + + this._defaults = { + chainId: options.chain.chain_id, + mode: 'grant', + authorizationTypeUrl: AUTHORIZATION_TYPES[0].type.typeUrl, + customTypeUrl: false, + grantee: '', + filterTypeUrl: FILTER_TYPES[0].type.typeUrl, + filterKeys: '', + filterMsgs: '{}', + funds: [], + contract: '', + calls: 10, + limitTypeUrl: LIMIT_TYPES[0].type.typeUrl, + msgTypeUrl: ACTION_TYPES[0].type.typeUrl, + } + } + + encode({ + chainId, + mode, + authorizationTypeUrl, + grantee, + msgTypeUrl, + filterKeys, + filterMsgs, + filterTypeUrl, + funds, + contract, + limitTypeUrl, + calls, + }: AuthzGrantRevokeData): UnifiedCosmosMsg[] { + const parsedFilterMsgs = JSON5.parse(filterMsgs) + const filter: Any | undefined = FILTER_TYPES.find( + ({ type: { typeUrl } }) => typeUrl === filterTypeUrl + )?.type.toProtoMsg({ + // AcceptedMessageKeysFilter + keys: filterKeys.split(',').map((k) => k.trim()), + // AcceptedMessagesFilter + messages: (Array.isArray(parsedFilterMsgs) + ? parsedFilterMsgs + : [parsedFilterMsgs] + ).map((m: unknown) => toUtf8(JSON.stringify(m))), + }) + + const limit: Any | undefined = LIMIT_TYPES.find( + ({ type: { typeUrl } }) => typeUrl === limitTypeUrl + )?.type.toProtoMsg({ + // MaxCallsLimit + remaining: BigInt(calls), + // CombinedLimit + callsRemaining: BigInt(calls), + // MaxFundsLimit + // CombinedLimit + amounts: funds.map(({ denom, amount, decimals }) => ({ + amount: convertDenomToMicroDenomStringWithDecimals(amount, decimals), + denom, + })), + }) + + let authorization: Any | undefined + if (mode === 'grant') { + authorization = AUTHORIZATION_TYPES.find( + ({ type: { typeUrl } }) => typeUrl === authorizationTypeUrl + )?.type.toProtoMsg({ + // GenericAuthorization + msg: msgTypeUrl, + // SendAuthorization + spendLimit: funds.map(({ denom, amount, decimals }) => ({ + amount: convertDenomToMicroDenomStringWithDecimals(amount, decimals), + denom, + })), + allowList: [], + // ContractExecutionAuthorization + // ContractMigrationAuthorization + grants: [ + { + contract, + filter: filter as any, + limit: limit as any, + }, + ], + }) + + if (!authorization) { + throw new Error('Unknown authorization type') + } + } + + // Expiration set to 10 years. + const expiration = new Date() + expiration.setFullYear(expiration.getFullYear() + 10) + // Encoder needs a whole number of seconds. + expiration.setMilliseconds(0) + + return maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + chainId, + makeStargateMessage({ + stargate: { + typeUrl: mode === 'grant' ? MsgGrant.typeUrl : MsgRevoke.typeUrl, + value: { + ...(mode === 'grant' && authorization + ? { + grant: { + authorization, + expiration, + }, + } + : { + msgTypeUrl, + }), + grantee, + granter: getChainAddressForActionOptions(this.options, chainId), + }, + }, + }) + ) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + if ( + !isDecodedStargateMsg(decodedMessage, [MsgGrant, MsgRevoke], { + grantee: {}, + granter: {}, + }) + ) { + return false + } + + // Some additional checks on MsgGrant types. + if (decodedMessage.stargate.typeUrl === MsgGrant.typeUrl) { + const grant = (decodedMessage.stargate.value as MsgGrant).grant + const grantAuthorizationTypeUrl = + grant && + // If not auto-decoded, will be Any. This should be the case for the + // CosmWasm contract authorizations. $typeUrl will be Any which is + // unhelpful. + (grant.authorization?.typeUrl || + // If auto-decoded, such as Generic or Send, this will be set instead. + grant.authorization?.$typeUrl) + + if ( + !grant || + !grantAuthorizationTypeUrl || + !AUTHORIZATION_TYPES.some( + ({ type }) => type.typeUrl === grantAuthorizationTypeUrl + ) + ) { + return false + } + + if ( + grantAuthorizationTypeUrl === ContractExecutionAuthorization.typeUrl || + grantAuthorizationTypeUrl === ContractMigrationAuthorization.typeUrl + ) { + const grants: ContractGrant[] = ( + grant.authorization as + | ContractExecutionAuthorization + | ContractMigrationAuthorization + ).grants + + // Supports only one grant. + if (grants.length !== 1) { + return false + } + + const { filter, limit } = grants[0] + + // Type-check. Should always pass until new types are added. + if ( + !limit?.$typeUrl || + !filter?.$typeUrl || + !LIMIT_TYPES.some( + ({ type: { typeUrl } }) => typeUrl === limit.$typeUrl + ) || + !FILTER_TYPES.some( + ({ type: { typeUrl } }) => typeUrl === filter.$typeUrl + ) + ) { + return false + } + } + } + + // Match all MsgRevoke types, no filtering needed. + + return true + } + + async decode([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): Promise> { + if (decodedMessage.stargate.typeUrl === MsgGrant.typeUrl) { + const { grant } = decodedMessage.stargate.value as MsgGrant + const grantAuthorizationTypeUrl = + grant && + // If not auto-decoded, will be Any. This should be the case for the + // CosmWasm contract authorizations. $typeUrl will be Any which is + // unhelpful. + (grant.authorization?.typeUrl || + // If auto-decoded, such as Generic or Send, this will be set instead. + grant.authorization?.$typeUrl) + + const funds = + grant && grantAuthorizationTypeUrl + ? grantAuthorizationTypeUrl === SendAuthorization.typeUrl + ? (grant.authorization as SendAuthorization).spendLimit + : grantAuthorizationTypeUrl === + ContractExecutionAuthorization.typeUrl || + grantAuthorizationTypeUrl === + ContractMigrationAuthorization.typeUrl + ? ( + grant.authorization as + | ContractExecutionAuthorization + | ContractMigrationAuthorization + ).grants[0]?.limit?.amounts + : undefined + : undefined + + const tokens = await Promise.all( + funds?.map(({ denom }) => + this.options.queryClient.fetchQuery( + tokenQueries.info(this.options.queryClient, { + chainId, + type: TokenType.Native, + denomOrAddress: denom, + }) + ) + ) || [] + ) + + const grantMsg = decodedMessage.stargate.value as MsgGrant + const authorizationTypeUrl = grantAuthorizationTypeUrl + + // Type-checks. Should already be checked in match. + if (!authorizationTypeUrl || !grant) { + throw new Error('Unknown authorization type') + } + + switch (authorizationTypeUrl) { + case GenericAuthorization.typeUrl: { + const msgTypeUrl = (grant.authorization as GenericAuthorization).msg + return { + chainId, + mode: 'grant', + authorizationTypeUrl, + customTypeUrl: !ACTION_TYPES.some( + ({ type: { typeUrl } }) => typeUrl === msgTypeUrl + ), + msgTypeUrl, + grantee: grantMsg.grantee, + } + } + + case SendAuthorization.typeUrl: { + const { spendLimit } = grant.authorization as SendAuthorization + + return { + chainId, + mode: 'grant', + authorizationTypeUrl, + customTypeUrl: false, + grantee: grantMsg.grantee, + funds: + spendLimit.map(({ denom, amount }) => { + const decimals = + tokens.find((t) => t.denomOrAddress === denom)?.decimals || 0 + return { + denom, + amount: convertMicroDenomToDenomWithDecimals( + amount, + decimals + ), + decimals, + } + }) ?? [], + } + } + + case ContractExecutionAuthorization.typeUrl: + case ContractMigrationAuthorization.typeUrl: { + // Supports only one grant. + const [{ contract, filter, limit }]: ContractGrant[] = ( + grant.authorization as + | ContractExecutionAuthorization + | ContractMigrationAuthorization + ).grants + + // Type-check. Should always pass until new types are added. + if (!limit?.$typeUrl || !filter?.$typeUrl) { + throw new Error('Unknown limit or filter type') + } + + const filterMsgs = + filter.$typeUrl === AcceptedMessagesFilter.typeUrl + ? (filter.messages as Uint8Array[]).map((msg) => + JSON.parse(fromUtf8(msg)) + ) + : [] + + return { + chainId, + mode: 'grant', + authorizationTypeUrl, + customTypeUrl: false, + grantee: decodedMessage.stargate.value.grantee, + funds: + limit.amounts?.map(({ denom, amount }) => { + const decimals = + tokens.find((t) => t.denomOrAddress === denom)?.decimals || 0 + return { + denom, + amount: convertMicroDenomToDenomWithDecimals( + amount, + decimals + ), + decimals, + } + }) ?? [], + contract, + filterTypeUrl: filter.$typeUrl, + filterKeys: + filter.$typeUrl === AcceptedMessageKeysFilter.typeUrl + ? filter.keys.join() + : '', + filterMsgs: JSON.stringify( + filterMsgs.length === 0 + ? {} + : filterMsgs.length === 1 + ? filterMsgs[0] + : filterMsgs, + null, + 2 + ), + limitTypeUrl: limit.$typeUrl, + calls: + limit.$typeUrl === MaxCallsLimit.typeUrl + ? Number(limit.remaining) + : limit.$typeUrl === CombinedLimit.typeUrl + ? Number(limit.callsRemaining) + : 0, + } + } + + default: + // Should already be checked in match. + throw new Error('Unknown authorization type') + } + } else if (decodedMessage.stargate.typeUrl === MsgRevoke.typeUrl) { + const msgTypeUrl = decodedMessage.stargate.value.msgTypeUrl + + return { + chainId, + mode: 'revoke', + customTypeUrl: !ACTION_TYPES.some( + ({ type: { typeUrl } }) => typeUrl === msgTypeUrl + ), + grantee: decodedMessage.stargate.value.grantee, + msgTypeUrl, + } + } + + // Should already be checked in match. + throw new Error('Unknown message type') + } +} diff --git a/packages/stateful/actions/core/authorizations/AuthzGrantRevoke/types.ts b/packages/stateful/actions/core/actions/AuthzGrantRevoke/types.ts similarity index 100% rename from packages/stateful/actions/core/authorizations/AuthzGrantRevoke/types.ts rename to packages/stateful/actions/core/actions/AuthzGrantRevoke/types.ts diff --git a/packages/stateful/actions/core/subdaos/BecomeSubDao/Component.stories.tsx b/packages/stateful/actions/core/actions/BecomeSubDao/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/subdaos/BecomeSubDao/Component.stories.tsx rename to packages/stateful/actions/core/actions/BecomeSubDao/Component.stories.tsx diff --git a/packages/stateful/actions/core/subdaos/BecomeSubDao/Component.tsx b/packages/stateful/actions/core/actions/BecomeSubDao/Component.tsx similarity index 93% rename from packages/stateful/actions/core/subdaos/BecomeSubDao/Component.tsx rename to packages/stateful/actions/core/actions/BecomeSubDao/Component.tsx index 7178c132a..8bc1f07aa 100644 --- a/packages/stateful/actions/core/subdaos/BecomeSubDao/Component.tsx +++ b/packages/stateful/actions/core/actions/BecomeSubDao/Component.tsx @@ -2,13 +2,15 @@ import { ComponentType } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' -import { InputErrorMessage, InputLabel } from '@dao-dao/stateless' +import { + InputErrorMessage, + InputLabel, + useActionOptions, +} from '@dao-dao/stateless' import { AddressInputProps } from '@dao-dao/types' import { ActionComponent } from '@dao-dao/types/actions' import { makeValidateAddress, validateRequired } from '@dao-dao/utils' -import { useActionOptions } from '../../../react' - export type BecomeSubDaoData = { admin: string } diff --git a/packages/stateful/actions/core/subdaos/BecomeSubDao/README.md b/packages/stateful/actions/core/actions/BecomeSubDao/README.md similarity index 100% rename from packages/stateful/actions/core/subdaos/BecomeSubDao/README.md rename to packages/stateful/actions/core/actions/BecomeSubDao/README.md diff --git a/packages/stateful/actions/core/actions/BecomeSubDao/index.tsx b/packages/stateful/actions/core/actions/BecomeSubDao/index.tsx new file mode 100644 index 000000000..592ec082e --- /dev/null +++ b/packages/stateful/actions/core/actions/BecomeSubDao/index.tsx @@ -0,0 +1,81 @@ +import { ActionBase, BabyEmoji } from '@dao-dao/stateless' +import { UnifiedCosmosMsg } from '@dao-dao/types' +import { + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { + makeExecuteSmartContractMessage, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { AddressInput } from '../../../../components' +import { BecomeSubDaoComponent, BecomeSubDaoData } from './Component' + +const Component: ActionComponent = (props) => ( + +) + +export class BecomeSubDaoAction extends ActionBase { + public readonly key = ActionKey.BecomeSubDao + public readonly Component = Component + + protected _defaults: BecomeSubDaoData = { + admin: '', + } + + constructor(options: ActionOptions) { + super(options, { + Icon: BabyEmoji, + label: options.t('title.becomeSubDao'), + description: options.t('info.becomeSubDaoDescription'), + notReusable: true, + // If parent DAO exists, hide this action. + hideFromPicker: + options.context.type === ActionContextType.Dao && + options.context.dao.info.parentDao !== null, + }) + } + + encode({ admin }: BecomeSubDaoData): UnifiedCosmosMsg { + return makeExecuteSmartContractMessage({ + chainId: this.options.chain.chain_id, + sender: this.options.address, + contractAddress: this.options.address, + msg: { + nominate_admin: { + admin, + }, + }, + }) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + msg: { + nominate_admin: { + admin: {}, + }, + }, + }, + }, + }) + } + + decode([{ decodedMessage }]: ProcessedMessage[]): BecomeSubDaoData { + return { + admin: decodedMessage.wasm.execute.msg.nominate_admin.admin, + } + } +} diff --git a/packages/stateful/actions/core/advanced/BulkImport/Component.stories.tsx b/packages/stateful/actions/core/actions/BulkImport/Component.stories.tsx similarity index 79% rename from packages/stateful/actions/core/advanced/BulkImport/Component.stories.tsx rename to packages/stateful/actions/core/actions/BulkImport/Component.stories.tsx index 6f32b698c..370eea758 100644 --- a/packages/stateful/actions/core/advanced/BulkImport/Component.stories.tsx +++ b/packages/stateful/actions/core/actions/BulkImport/Component.stories.tsx @@ -8,7 +8,6 @@ import { import { SuspenseLoader } from '../../../../components/SuspenseLoader' import { Trans } from '../../../../components/Trans' -import { useLoadedActionsAndCategories } from '../../../react' import { BulkImportComponent } from './Component' export default { @@ -22,13 +21,7 @@ export default { } as ComponentMeta const Template: ComponentStory = (args) => ( - + ) export const Default = Template.bind({}) @@ -40,7 +33,6 @@ Default.args = { isCreating: true, errors: {}, options: { - loadedActions: {}, SuspenseLoader, Trans, }, diff --git a/packages/stateful/actions/core/actions/BulkImport/Component.tsx b/packages/stateful/actions/core/actions/BulkImport/Component.tsx new file mode 100644 index 000000000..f1a254dd6 --- /dev/null +++ b/packages/stateful/actions/core/actions/BulkImport/Component.tsx @@ -0,0 +1,233 @@ +import JSON5 from 'json5' +import cloneDeep from 'lodash.clonedeep' +import merge from 'lodash.merge' +import uniq from 'lodash.uniq' +import { parse as csvToJson } from 'papaparse' +import { ComponentType, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { + ActionsRenderer, + Button, + ButtonLink, + FileDropInput, + useActionsContext, +} from '@dao-dao/stateless' +import { SuspenseLoaderProps, TransProps } from '@dao-dao/types' +import { + ActionAndData, + ActionComponent, + ActionKey, +} from '@dao-dao/types/actions' +import { objectMatchesStructure } from '@dao-dao/utils' + +export type BulkImportOptions = { + SuspenseLoader: ComponentType + Trans: ComponentType +} + +export const BulkImportComponent: ActionComponent = ({ + addAction, + remove, + options: { SuspenseLoader, Trans }, +}) => { + const { t } = useTranslation() + const { actionMap } = useActionsContext() + + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [pendingActions, setPendingActions] = useState([]) + + const onSelect = (file: File) => { + setError('') + + if (file.type !== 'application/json' && file.type !== 'text/csv') { + setError(t('error.invalidFileTypeBulkImport')) + return + } + + // Read contents of the file. + const reader = new FileReader() + reader.readAsText(file) + reader.onload = () => { + if (typeof reader.result !== 'string') { + return + } + + setLoading(true) + try { + let data + try { + switch (file.type) { + case 'application/json': + data = JSON5.parse(reader.result) + break + case 'text/csv': + const parsedCsv = csvToJson(reader.result, { + header: true, + }).data.filter( + (obj: any) => + objectMatchesStructure(obj, { ACTION: {} }) && + obj.ACTION && + Object.values(ActionKey).includes(obj.ACTION) + ) + + data = { + actions: parsedCsv.map(({ ACTION, ...data }: any) => ({ + key: ACTION, + data, + })), + } + + if (!data.actions.length) { + throw new Error(t('error.invalidImportFormatCsv')) + } + + break + default: + throw new Error(t('error.invalidFileTypeBulkImport')) + } + } catch (err) { + setError(err instanceof Error ? err.message : `${err}`) + return + } + + // Validate data is list of `actions` with `key` present. Some actions + // take no `data`, so `data` is optional. + if ( + !objectMatchesStructure(data, { + actions: {}, + }) || + !Array.isArray(data.actions) || + data.actions.length === 0 || + data.actions.some( + (action: any) => + !objectMatchesStructure(action, { + key: {}, + }) + ) + ) { + setError(t('error.invalidImportFormatJson')) + return + } + + const actions = data.actions as { + key: any + data?: any + }[] + + // Verify the action key of each action is valid. + const invalidActionKeys = uniq( + actions.flatMap(({ key }) => + typeof key !== 'string' || !(key in actionMap) ? key : [] + ) + ) + if (invalidActionKeys.length > 0) { + setError( + t('error.invalidActionKeys', { + keys: invalidActionKeys.join(', '), + }) + ) + return + } + + // Error if any actions failed to load. + const firstErroredAction = actions.flatMap(({ key }) => + actionMap[key as keyof typeof actionMap]?.errored + ? actionMap[key as keyof typeof actionMap] + : [] + )[0] + if (firstErroredAction) { + setError( + t('error.actionFailedToLoad', { + action: firstErroredAction.metadata.label, + error: firstErroredAction.error!.message, + }) + ) + return + } + + setPendingActions( + actions.flatMap(({ key, data }): ActionAndData | [] => { + try { + // Existence validated above. + const action = actionMap[key as keyof typeof actionMap]! + return { + action, + // Use the action's defaults as a base, and then merge in the + // imported data, overriding any defaults. If data is undefined, + // then the action's defaults will be used. + data: merge( + {}, + cloneDeep(action.defaults), + action.transformImportData?.(data) || data + ), + } + } catch { + return [] + } + }) + ) + } finally { + setLoading(false) + } + } + reader.onerror = () => { + console.error(reader.error) + setError(reader.error?.message ?? t('error.loadingData')) + setLoading(false) + } + } + + const importPending = () => { + // Add all pending actions to the form. + pendingActions.forEach(({ action: { key }, data }) => + addAction?.({ + actionKey: key, + data, + }) + ) + // Remove this bulk import action from the form. + remove?.() + } + + return pendingActions.length > 0 ? ( + <> +

{t('info.reviewActionImportData')}

+ + + +
+ + + +
+ + ) : ( + <> +

+ + Choose a JSON or CSV file below that matches the format described in{' '} + + this guide + + . + +

+ + + + {error &&

{error}

} + + ) +} diff --git a/packages/stateful/actions/core/advanced/BulkImport/README.md b/packages/stateful/actions/core/actions/BulkImport/README.md similarity index 100% rename from packages/stateful/actions/core/advanced/BulkImport/README.md rename to packages/stateful/actions/core/actions/BulkImport/README.md diff --git a/packages/stateful/actions/core/actions/BulkImport/index.tsx b/packages/stateful/actions/core/actions/BulkImport/index.tsx new file mode 100644 index 000000000..18a5eac9c --- /dev/null +++ b/packages/stateful/actions/core/actions/BulkImport/index.tsx @@ -0,0 +1,52 @@ +import { ActionBase, FileFolderEmoji } from '@dao-dao/stateless' +import { UnifiedCosmosMsg } from '@dao-dao/types' +import { + ActionComponent, + ActionKey, + ActionMatch, + ActionOptions, +} from '@dao-dao/types/actions' + +import { SuspenseLoader } from '../../../../components/SuspenseLoader' +import { Trans } from '../../../../components/Trans' +import { BulkImportComponent } from './Component' + +const Component: ActionComponent = (props) => ( + +) + +// This action is not intended to output any messages. It is just an interface +// that can add other actions. +export class BulkImportAction extends ActionBase<{}> { + public readonly key = ActionKey.BulkImport + public readonly Component = Component + + protected _defaults = {} + + constructor(options: ActionOptions) { + super(options, { + Icon: FileFolderEmoji, + label: options.t('title.bulkImportActions'), + description: options.t('info.bulkImportActionsDescription'), + notReusable: true, + }) + } + + encode(): UnifiedCosmosMsg[] { + return [] + } + + match(): ActionMatch { + return false + } + + decode() { + return {} + } +} diff --git a/packages/stateful/actions/core/nfts/BurnNft/Component.stories.tsx b/packages/stateful/actions/core/actions/BurnNft/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/nfts/BurnNft/Component.stories.tsx rename to packages/stateful/actions/core/actions/BurnNft/Component.stories.tsx diff --git a/packages/stateful/actions/core/nfts/BurnNft/Component.tsx b/packages/stateful/actions/core/actions/BurnNft/Component.tsx similarity index 100% rename from packages/stateful/actions/core/nfts/BurnNft/Component.tsx rename to packages/stateful/actions/core/actions/BurnNft/Component.tsx diff --git a/packages/stateful/actions/core/nfts/BurnNft/README.md b/packages/stateful/actions/core/actions/BurnNft/README.md similarity index 100% rename from packages/stateful/actions/core/nfts/BurnNft/README.md rename to packages/stateful/actions/core/actions/BurnNft/README.md diff --git a/packages/stateful/actions/core/nfts/BurnNft/index.tsx b/packages/stateful/actions/core/actions/BurnNft/index.tsx similarity index 54% rename from packages/stateful/actions/core/nfts/BurnNft/index.tsx rename to packages/stateful/actions/core/actions/BurnNft/index.tsx index 17c2fe860..9d5724ad0 100644 --- a/packages/stateful/actions/core/nfts/BurnNft/index.tsx +++ b/packages/stateful/actions/core/actions/BurnNft/index.tsx @@ -1,4 +1,3 @@ -import { useCallback } from 'react' import { useFormContext } from 'react-hook-form' import { constSelector } from 'recoil' @@ -7,101 +6,35 @@ import { nftCardInfoSelector, walletLazyNftCardInfosSelector, } from '@dao-dao/state/recoil' -import { FireEmoji, useCachedLoadingWithError } from '@dao-dao/stateless' +import { + ActionBase, + FireEmoji, + useActionOptions, + useCachedLoadingWithError, +} from '@dao-dao/stateless' import { ActionComponent, ActionContextType, ActionKey, - ActionMaker, + ActionMatch, + ActionOptions, LazyNftCardInfo, LoadingDataWithError, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, + ProcessedMessage, + UnifiedCosmosMsg, } from '@dao-dao/types' import { combineLoadingDataWithErrors, - decodePolytoneExecuteMsg, - makeWasmMessage, - maybeMakePolytoneExecuteMessage, + getChainAddressForActionOptions, + makeExecuteSmartContractMessage, + maybeMakePolytoneExecuteMessages, objectMatchesStructure, } from '@dao-dao/utils' import { NftSelectionModal } from '../../../../components' import { useCw721CommonGovernanceTokenInfoIfExists } from '../../../../voting-module-adapter' -import { useActionOptions } from '../../../react' import { BurnNft, BurnNftData } from './Component' -const useDefaults: UseDefaults = () => ({ - chainId: '', - collection: '', - tokenId: '', -}) - -const useTransformToCosmos: UseTransformToCosmos = () => { - const { - chain: { chain_id: currentChainId }, - } = useActionOptions() - - return useCallback( - ({ chainId, collection, tokenId }: BurnNftData) => - maybeMakePolytoneExecuteMessage( - currentChainId, - chainId, - makeWasmMessage({ - wasm: { - execute: { - contract_addr: collection, - funds: [], - msg: { - burn: { - token_id: tokenId, - }, - }, - }, - }, - }) - ), - [currentChainId] - ) -} - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - let chainId = useActionOptions().chain.chain_id - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - } - - return objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - burn: { - token_id: {}, - }, - }, - }, - }, - }) - ? { - match: true, - data: { - chainId, - collection: msg.wasm.execute.contract_addr, - tokenId: msg.wasm.execute.msg.burn.token_id, - }, - } - : { - match: false, - } -} - const Component: ActionComponent = (props) => { const { context, @@ -159,15 +92,69 @@ const Component: ActionComponent = (props) => { ) } -export const makeBurnNftAction: ActionMaker = ({ t }) => { - return { - key: ActionKey.BurnNft, - Icon: FireEmoji, - label: t('title.burnNft'), - description: t('info.burnNftDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, +export class BurnNftAction extends ActionBase { + public readonly key = ActionKey.BurnNft + public readonly Component = Component + + protected _defaults: BurnNftData = { + chainId: '', + collection: '', + tokenId: '', + } + + constructor(options: ActionOptions) { + super(options, { + Icon: FireEmoji, + label: options.t('title.burnNft'), + description: options.t('info.burnNftDescription'), + // This must be after the Press widget's Delete Post action. + matchPriority: -80, + }) + } + + encode({ chainId, collection, tokenId }: BurnNftData): UnifiedCosmosMsg[] { + return maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + chainId, + makeExecuteSmartContractMessage({ + chainId, + sender: getChainAddressForActionOptions(this.options, chainId) || '', + contractAddress: collection, + msg: { + burn: { + token_id: tokenId, + }, + }, + }) + ) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + burn: { + token_id: {}, + }, + }, + }, + }, + }) + } + + decode([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): BurnNftData { + return { + chainId, + collection: decodedMessage.wasm.execute.contract_addr, + tokenId: decodedMessage.wasm.execute.msg.burn.token_id, + } } } diff --git a/packages/stateful/actions/core/treasury/CommunityPoolDeposit/Component.tsx b/packages/stateful/actions/core/actions/CommunityPoolDeposit/Component.tsx similarity index 50% rename from packages/stateful/actions/core/treasury/CommunityPoolDeposit/Component.tsx rename to packages/stateful/actions/core/actions/CommunityPoolDeposit/Component.tsx index bddb4944e..051237699 100644 --- a/packages/stateful/actions/core/treasury/CommunityPoolDeposit/Component.tsx +++ b/packages/stateful/actions/core/actions/CommunityPoolDeposit/Component.tsx @@ -1,4 +1,3 @@ -import { useCallback, useEffect } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' @@ -8,11 +7,9 @@ import { TokenInput, } from '@dao-dao/stateless' import { GenericTokenBalance, LoadingData } from '@dao-dao/types' -import { ActionComponent, ActionContextType } from '@dao-dao/types/actions' +import { ActionComponent } from '@dao-dao/types/actions' import { convertMicroDenomToDenomWithDecimals } from '@dao-dao/utils' -import { useActionOptions } from '../../../react/context' - export type CommunityPoolDepositData = { chainId: string amount: number @@ -28,106 +25,44 @@ export const CommunityPoolDepositComponent: ActionComponent< CommunityPoolDepositOptions > = ({ fieldNamePrefix, isCreating, errors, options: { tokens } }) => { const { t } = useTranslation() - const { context } = useActionOptions() - const { register, watch, setValue, setError, clearErrors } = + const { register, watch, setValue } = useFormContext() const spendChainId = watch((fieldNamePrefix + 'chainId') as 'chainId') const spendAmount = watch((fieldNamePrefix + 'amount') as 'amount') const spendDenom = watch((fieldNamePrefix + 'denom') as 'denom') - const validatePossibleSpend = useCallback( - (chainId: string, denom: string, amount: number): string | boolean => { - if (tokens.loading) { - return true - } - - const insufficientBalanceI18nKey = - context.type === ActionContextType.Wallet - ? 'error.insufficientWalletBalance' - : 'error.cantSpendMoreThanTreasury' - - const tokenBalance = tokens.data.find( - ({ token }) => - token.chainId === chainId && token.denomOrAddress === denom - ) - if (tokenBalance) { - return ( - amount <= Number(tokenBalance.balance) || - t(insufficientBalanceI18nKey, { - amount: convertMicroDenomToDenomWithDecimals( - tokenBalance.balance, - tokenBalance.token.decimals - ).toLocaleString(undefined, { - maximumFractionDigits: tokenBalance.token.decimals, - }), - tokenSymbol: tokenBalance.token.symbol, - }) - ) - } - - return t('error.unknownDenom', { denom }) - }, - [context.type, t, tokens] - ) - - // Update amount+denom combo error each time either field is updated - // instead of setting errors individually on each field. Since we only - // show one or the other and can't detect which error is newer, this - // would lead to the error not updating if amount set an error and then - // denom was changed. - useEffect(() => { - // Prevent infinite loops by not setting errors if already set, and only - // clearing errors unless already set. - const currentError = errors?._error - - if (!spendDenom || !spendAmount) { - if (currentError) { - clearErrors((fieldNamePrefix + '_error') as '_error') - } - return - } - - const validation = validatePossibleSpend( - spendChainId, - spendDenom, - spendAmount - ) - if (validation === true) { - if (currentError) { - clearErrors((fieldNamePrefix + '_error') as '_error') - } - } else if (typeof validation === 'string') { - if (!currentError || currentError.message !== validation) { - setError((fieldNamePrefix + '_error') as '_error', { - type: 'custom', - message: validation, - }) - } - } - }, [ - spendAmount, - spendDenom, - setError, - clearErrors, - validatePossibleSpend, - fieldNamePrefix, - errors?._error, - spendChainId, - ]) - const selectedToken = tokens.loading ? undefined : tokens.data.find( ({ token }) => token.chainId === spendChainId && token.denomOrAddress === spendDenom ) - const balance = convertMicroDenomToDenomWithDecimals( + const selectedDecimals = selectedToken?.token.decimals ?? 0 + const selectedBalance = convertMicroDenomToDenomWithDecimals( selectedToken?.balance ?? 0, - selectedToken?.token.decimals ?? 0 + selectedDecimals ) + // A warning if the denom was not found in the treasury or the amount is too + // high. We don't want to make this an error because often people want to + // spend funds that a previous action makes available, so just show a warning. + const symbol = selectedToken?.token.symbol || spendDenom + const warning = + !isCreating || tokens.loading || !spendDenom + ? undefined + : !selectedToken + ? t('error.unknownDenom', { denom: spendDenom }) + : spendAmount > selectedBalance + ? t('error.insufficientFundsWarning', { + amount: selectedBalance.toLocaleString(undefined, { + maximumFractionDigits: selectedDecimals, + }), + tokenSymbol: symbol, + }) + : undefined + return ( <>
@@ -138,15 +73,8 @@ export const CommunityPoolDepositComponent: ActionComponent< register, fieldName: (fieldNamePrefix + 'amount') as 'amount', error: errors?.amount, - min: convertMicroDenomToDenomWithDecimals( - 1, - selectedToken?.token.decimals ?? 0 - ), - max: balance, - step: convertMicroDenomToDenomWithDecimals( - 1, - selectedToken?.token.decimals ?? 0 - ), + min: convertMicroDenomToDenomWithDecimals(1, selectedDecimals), + step: convertMicroDenomToDenomWithDecimals(1, selectedDecimals), }} onSelectToken={({ chainId, denomOrAddress }) => { setValue((fieldNamePrefix + 'chainId') as 'chainId', chainId) @@ -176,11 +104,12 @@ export const CommunityPoolDepositComponent: ActionComponent< />
- {(errors?.amount || errors?.denom || errors?._error) && ( + {(errors?.amount || errors?.denom || errors?._error || warning) && (
+
)} @@ -189,7 +118,7 @@ export const CommunityPoolDepositComponent: ActionComponent<

{t('title.balance')}:

= ( + props +) => { + const { watch } = useFormContext() + + const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') + const denom = watch((props.fieldNamePrefix + 'denom') as 'denom') + + const tokens = useTokenBalances({ + filter: TokenType.Native, + // Load selected token when not creating, in case it is no longer returned + // in the list of all tokens for the given account. + additionalTokens: props.isCreating + ? undefined + : [ + { + chainId, + type: TokenType.Native, + denomOrAddress: denom, + }, + ], + }) + + return ( + + ) +} + +export class CommunityPoolDepositAction extends ActionBase { + public readonly key = ActionKey.CommunityPoolDeposit + public readonly Component = Component + + constructor(options: ActionOptions) { + // Neutron does not use the x/distribution community pool. + if ( + options.chain.chain_id === ChainId.NeutronMainnet || + options.chain.chain_id === ChainId.NeutronTestnet + ) { + throw new Error('Neutron does not support community pool deposits') + } + + super(options, { + Icon: DownArrowEmoji, + label: options.t('title.communityPoolDeposit'), + description: options.t('info.communityPoolDepositDescription'), + }) + + this.defaults = { + chainId: options.chain.chain_id, + amount: 100, + denom: options.chainContext.nativeToken?.denomOrAddress || '', + } + } + + async encode({ + chainId, + amount, + denom, + }: CommunityPoolDepositData): Promise { + const depositor = getChainAddressForActionOptions(this.options, chainId) + if (!depositor) { + throw new Error('No depositor address found for chain') + } + + const token = await this.options.queryClient.fetchQuery( + tokenQueries.info(this.options.queryClient, { + chainId, + type: TokenType.Native, + denomOrAddress: denom, + }) + ) + + return maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + chainId, + makeStargateMessage({ + stargate: { + typeUrl: MsgFundCommunityPool.typeUrl, + value: MsgFundCommunityPool.fromPartial({ + depositor, + amount: coins( + convertDenomToMicroDenomStringWithDecimals( + amount, + token.decimals + ), + denom + ), + }), + }, + }) + ) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return isDecodedStargateMsg(decodedMessage, MsgFundCommunityPool, { + depositor: {}, + amount: [ + { + amount: {}, + denom: {}, + }, + ], + }) + } + + async decode([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): Promise { + const { amount, denom } = decodedMessage.stargate.value.amount[0] + const { decimals } = await this.options.queryClient.fetchQuery( + tokenQueries.info(this.options.queryClient, { + chainId, + type: TokenType.Native, + denomOrAddress: denom, + }) + ) + + return { + chainId, + amount: convertMicroDenomToDenomWithDecimals(amount, decimals), + denom, + } + } +} diff --git a/packages/stateful/actions/core/treasury/CommunityPoolSpend/Component.tsx b/packages/stateful/actions/core/actions/CommunityPoolSpend/Component.tsx similarity index 100% rename from packages/stateful/actions/core/treasury/CommunityPoolSpend/Component.tsx rename to packages/stateful/actions/core/actions/CommunityPoolSpend/Component.tsx diff --git a/packages/stateful/actions/core/treasury/CommunityPoolSpend/README.md b/packages/stateful/actions/core/actions/CommunityPoolSpend/README.md similarity index 100% rename from packages/stateful/actions/core/treasury/CommunityPoolSpend/README.md rename to packages/stateful/actions/core/actions/CommunityPoolSpend/README.md diff --git a/packages/stateful/actions/core/actions/CommunityPoolSpend/index.tsx b/packages/stateful/actions/core/actions/CommunityPoolSpend/index.tsx new file mode 100644 index 000000000..19edd94b9 --- /dev/null +++ b/packages/stateful/actions/core/actions/CommunityPoolSpend/index.tsx @@ -0,0 +1,96 @@ +import { ActionBase, MoneyEmoji } from '@dao-dao/stateless' +import { UnifiedCosmosMsg, makeStargateMessage } from '@dao-dao/types' +import { + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { MsgCommunityPoolSpend } from '@dao-dao/types/protobuf/codegen/cosmos/distribution/v1beta1/tx' +import { isDecodedStargateMsg } from '@dao-dao/utils' + +import { PayEntityDisplay } from '../../../../components/PayEntityDisplay' +import { + CommunityPoolSpendComponent, + CommunityPoolSpendData, +} from './Component' + +const Component: ActionComponent = ( + props +) => ( + +) + +export class CommunityPoolSpendAction extends ActionBase { + public readonly key = ActionKey.CommunityPoolSpend + public readonly Component = Component + + protected _defaults: CommunityPoolSpendData = { + authority: '', + recipient: '', + funds: [], + } + + constructor(options: ActionOptions) { + if (options.context.type !== ActionContextType.Gov) { + throw new Error( + 'Community pool spends are only allowed in governance proposals' + ) + } + + super(options, { + Icon: MoneyEmoji, + label: options.t('title.spend'), + description: options.t('info.spendActionDescription', { + context: options.context.type, + }), + // The normal Spend action will automatically create community pool spends + // when used inside a governance proposal context. This community pool + // spend action is just for display purposes, since the Spend action only + // allows selecting one token at a time, whereas community pool spends can + // contain multiple tokens. Thus, don't allow choosing this action when + // creating a proposal, but still render it. + hideFromPicker: true, + }) + } + + encode({ + authority, + recipient, + funds, + }: CommunityPoolSpendData): UnifiedCosmosMsg { + return makeStargateMessage({ + stargate: { + typeUrl: MsgCommunityPoolSpend.typeUrl, + value: MsgCommunityPoolSpend.fromPartial({ + authority, + recipient, + amount: funds, + }), + }, + }) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return isDecodedStargateMsg(decodedMessage, MsgCommunityPoolSpend, { + authority: {}, + recipient: {}, + amount: {}, + }) + } + + decode([{ decodedMessage }]: ProcessedMessage[]): CommunityPoolSpendData { + return { + authority: decodedMessage.stargate.value.authority, + recipient: decodedMessage.stargate.value.recipient, + funds: decodedMessage.stargate.value.amount, + } + } +} diff --git a/packages/stateful/actions/core/valence/ConfigureRebalancer/Component.stories.tsx b/packages/stateful/actions/core/actions/ConfigureRebalancer/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/valence/ConfigureRebalancer/Component.stories.tsx rename to packages/stateful/actions/core/actions/ConfigureRebalancer/Component.stories.tsx diff --git a/packages/stateful/actions/core/valence/ConfigureRebalancer/Component.tsx b/packages/stateful/actions/core/actions/ConfigureRebalancer/Component.tsx similarity index 100% rename from packages/stateful/actions/core/valence/ConfigureRebalancer/Component.tsx rename to packages/stateful/actions/core/actions/ConfigureRebalancer/Component.tsx diff --git a/packages/stateful/actions/core/valence/ConfigureRebalancer/README.md b/packages/stateful/actions/core/actions/ConfigureRebalancer/README.md similarity index 100% rename from packages/stateful/actions/core/valence/ConfigureRebalancer/README.md rename to packages/stateful/actions/core/actions/ConfigureRebalancer/README.md diff --git a/packages/stateful/actions/core/valence/ConfigureRebalancer/index.tsx b/packages/stateful/actions/core/actions/ConfigureRebalancer/index.tsx similarity index 57% rename from packages/stateful/actions/core/valence/ConfigureRebalancer/index.tsx rename to packages/stateful/actions/core/actions/ConfigureRebalancer/index.tsx index db77b7652..c8af2ba7c 100644 --- a/packages/stateful/actions/core/valence/ConfigureRebalancer/index.tsx +++ b/packages/stateful/actions/core/actions/ConfigureRebalancer/index.tsx @@ -1,7 +1,7 @@ import { fromBase64, fromUtf8 } from '@cosmjs/encoding' import { useQueries, useQueryClient } from '@tanstack/react-query' import cloneDeep from 'lodash.clonedeep' -import { useCallback, useEffect } from 'react' +import { useEffect } from 'react' import { useFormContext } from 'react-hook-form' import { waitForAll } from 'recoil' @@ -12,11 +12,14 @@ import { } from '@dao-dao/state' import { usdPriceSelector } from '@dao-dao/state/recoil/selectors' import { + ActionBase, BalanceEmoji, ChainProvider, DaoSupportedChainPickerInput, + useActionOptions, useCachedLoading, useCachedLoadingWithError, + useInitializedActionForKey, useUpdatingRef, } from '@dao-dao/stateless' import { @@ -24,7 +27,7 @@ import { GenericTokenBalance, LoadingData, TokenType, - UseDecodedCosmosMsg, + UnifiedCosmosMsg, ValenceAccount, } from '@dao-dao/types' import { @@ -32,9 +35,9 @@ import { ActionComponent, ActionContextType, ActionKey, - ActionMaker, - UseDefaults, - UseTransformToCosmos, + ActionMatch, + ActionOptions, + ProcessedMessage, } from '@dao-dao/types/actions' import { ExecuteMsg as ValenceAccountExecuteMsg } from '@dao-dao/types/contracts/ValenceAccount' import { @@ -46,13 +49,13 @@ import { VALENCE_SUPPORTED_CHAINS, convertDenomToMicroDenomStringWithDecimals, convertMicroDenomToDenomWithDecimals, - decodePolytoneExecuteMsg, encodeJsonToBase64, getAccount, getChainAddressForActionOptions, + getSupportedChainConfig, makeCombineQueryResultsIntoLoadingData, makeWasmMessage, - maybeMakePolytoneExecuteMessage, + maybeMakePolytoneExecuteMessages, mustGetSupportedChainConfig, objectMatchesStructure, } from '@dao-dao/utils' @@ -63,7 +66,6 @@ import { useQueryLoadingDataWithError, } from '../../../../hooks' import { useTokenBalances } from '../../../hooks/useTokenBalances' -import { useActionForKey, useActionOptions } from '../../../react' import { CreateValenceAccountData } from '../CreateValenceAccount/Component' import { ConfigureRebalancerData, @@ -111,21 +113,23 @@ const Component: ActionComponent = ( ? (props.allActionsWithData[existingCreateValenceAccountActionIndex] ?.data as CreateValenceAccountData) : undefined - const createValenceAccountActionDefaults = useActionForKey( + const createValenceAccountAction = useInitializedActionForKey( ActionKey.CreateValenceAccount - )?.useDefaults() + ) // Can add create valence account if no existing action and defaults loaded. const canAddCreateValenceAccountAction = + props.isCreating && !existingValenceAccount && (existingCreateValenceAccountActionIndex === -1 || existingCreateValenceAccountActionIndex > props.index) && - createValenceAccountActionDefaults + !createValenceAccountAction.loading && + !createValenceAccountAction.errored const addCreateValenceAccountActionIfNeededRef = useUpdatingRef(() => { if (canAddCreateValenceAccountAction) { props.addAction?.( { actionKey: ActionKey.CreateValenceAccount, - data: cloneDeep(createValenceAccountActionDefaults), + data: cloneDeep(createValenceAccountAction.data.defaults), }, props.index ) @@ -155,6 +159,7 @@ const Component: ActionComponent = ( chainId, address: generatedValenceAddress.data, config: { + admin: '', rebalancer: null, }, } @@ -309,195 +314,60 @@ const Component: ActionComponent = ( ) } -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - const queryClient = useQueryClient() - let chainId = useActionOptions().chain.chain_id - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - } - - let serviceName: string | undefined - let data: RebalancerData | RebalancerUpdateData | undefined - if ( - objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: {}, - }, - }, - }) - ) { - const serviceData = - 'register_to_service' in msg.wasm.execute.msg - ? msg.wasm.execute.msg.register_to_service - : 'update_service' in msg.wasm.execute.msg - ? msg.wasm.execute.msg.update_service - : undefined - if ( - objectMatchesStructure(serviceData, { - service_name: {}, - data: {}, - }) - ) { - serviceName = serviceData.service_name as string - data = JSON.parse(fromUtf8(fromBase64(serviceData.data as string))) - } - } +export class ConfigureRebalancerAction extends ActionBase { + public readonly key = ActionKey.ConfigureRebalancer + public readonly Component = Component - // Get target with min balance set. - const minBalanceTarget = data?.targets.find(({ min_balance }) => min_balance) - const minBalanceToken = useCachedLoading( - minBalanceTarget?.denom - ? genericTokenSelector({ - chainId, - type: TokenType.Native, - denomOrAddress: minBalanceTarget.denom, - }) - : undefined, - undefined - ) + private existingValenceAccount: ValenceAccount | undefined + private valenceChainId: string - const rebalancer = mustGetSupportedChainConfig(chainId).valence?.rebalancer - const whitelists = useQueryLoadingDataWithError( - valenceRebalancerExtraQueries.whitelistGenericTokens( - queryClient, - rebalancer - ? { - chainId, - address: rebalancer, - } - : undefined - ) - ) - - if ( - serviceName !== 'rebalancer' || - !data || - !objectMatchesStructure(data, { - base_denom: {}, - targets: {}, - pid: {}, - target_override_strategy: {}, - }) || - whitelists.loading || - whitelists.errored - ) { - return { - match: false, - } - } - - const kp = Number(data.pid?.p || -1) - const ki = Number(data.pid?.i || -1) - const kd = Number(data.pid?.d || -1) - - // Show custom PID fields if no preset found for these settings. - const showCustomPid = !pidPresets.some( - (preset) => preset.kp === kp && preset.ki === ki && preset.kd === kd - ) + constructor(options: ActionOptions) { + super(options, { + Icon: BalanceEmoji, + label: options.t('title.configureRebalancer'), + description: options.t('info.configureRebalancerDescription'), + notReusable: true, + }) - return { - match: true, - data: { - chainId, - trustee: - typeof data.trustee === 'string' - ? 'update_service' in msg.wasm.execute.msg && data.trustee === 'clear' - ? undefined - : data.trustee - : 'update_service' in msg.wasm.execute.msg && - typeof data.trustee === 'object' && - data.trustee && - 'set' in data.trustee - ? data.trustee.set - : undefined, - baseDenom: - data.base_denom || whitelists.data.baseDenoms[0].denomOrAddress, - tokens: data.targets.map(({ denom, bps }) => ({ - denom, - percent: bps / 100, - })), - pid: { - kp, - ki, - kd, - }, - showCustomPid, - maxLimit: - typeof data.max_limit_bps === 'number' - ? data.max_limit_bps / 100 - : undefined, - minBalance: - minBalanceTarget?.min_balance && !minBalanceToken.loading - ? { - denom: minBalanceTarget.denom, - amount: convertMicroDenomToDenomWithDecimals( - minBalanceTarget.min_balance, - minBalanceToken.data?.decimals ?? 0 - ), - } - : undefined, - targetOverrideStrategy: data.target_override_strategy || 'proportional', - }, + this.existingValenceAccount = getAccount({ + accounts: options.context.accounts, + types: [AccountType.Valence], + }) as ValenceAccount | undefined + this.valenceChainId = + this.existingValenceAccount?.chainId || VALENCE_SUPPORTED_CHAINS[0] } -} -export const makeConfigureRebalancerAction: ActionMaker< - ConfigureRebalancerData -> = (options) => { - const { - t, - chain: { chain_id: srcChainId }, - context, - } = options - - const valenceAccount = getAccount({ - accounts: context.accounts, - types: [AccountType.Valence], - }) as ValenceAccount | undefined - const chainId = valenceAccount?.chainId || VALENCE_SUPPORTED_CHAINS[0] - const rebalancer = mustGetSupportedChainConfig(chainId).valence?.rebalancer + async setup() { + const rebalancer = mustGetSupportedChainConfig(this.valenceChainId).valence + ?.rebalancer + if (!rebalancer) { + throw new Error('Missing rebalancer address.') + } - const useDefaults: UseDefaults = () => { - const queryClient = useQueryClient() - const whitelists = useQueryLoadingDataWithError( + const whitelists = await this.options.queryClient.fetchQuery( valenceRebalancerExtraQueries.whitelistGenericTokens( - queryClient, - rebalancer - ? { - chainId, - address: rebalancer, - } - : undefined + this.options.queryClient, + { + chainId: this.valenceChainId, + address: rebalancer, + } ) ) - const rebalancerConfig = valenceAccount?.config?.rebalancer?.config + const rebalancerConfig = + this.existingValenceAccount?.config?.rebalancer?.config const minBalanceTarget = rebalancerConfig?.targets.find( ({ min_balance }) => min_balance ) - const minBalanceToken = useCachedLoading( - minBalanceTarget?.denom - ? genericTokenSelector({ - chainId, + const minBalanceToken = minBalanceTarget + ? await this.options.queryClient.fetchQuery( + tokenQueries.info(this.options.queryClient, { + chainId: this.valenceChainId, type: TokenType.Native, denomOrAddress: minBalanceTarget.denom, }) - : undefined, - undefined - ) - - if (whitelists.loading) { - return - } else if (whitelists.errored) { - return whitelists.error - } + ) + : undefined const defaultPid: ConfigureRebalancerData['pid'] = rebalancerConfig ? { @@ -515,42 +385,41 @@ export const makeConfigureRebalancerAction: ActionMaker< preset.kd === defaultPid.kd ) - return { + this.defaults = { // If no valence account found, the action will detect this and add the // create account action automatically. - valenceAccount, - chainId, + valenceAccount: this.existingValenceAccount, + chainId: this.valenceChainId, trustee: rebalancerConfig?.trustee || undefined, baseDenom: - rebalancerConfig?.base_denom || - whitelists.data.baseDenoms[0].denomOrAddress, + rebalancerConfig?.base_denom || whitelists.baseDenoms[0].denomOrAddress, tokens: rebalancerConfig?.targets.map(({ denom, percentage }) => ({ denom, percent: Number(percentage) * 100, })) || [ { - denom: whitelists.data.baseDenoms[0].denomOrAddress, + denom: whitelists.baseDenoms[0].denomOrAddress, percent: 50, }, { - denom: whitelists.data.baseDenoms[1].denomOrAddress, + denom: whitelists.baseDenoms[1].denomOrAddress, percent: 50, }, ], pid: defaultPid, showCustomPid, - maxLimitBps: + maxLimit: rebalancerConfig?.max_limit && !isNaN(Number(rebalancerConfig.max_limit)) ? Number(rebalancerConfig.max_limit) : 500, minBalance: - minBalanceTarget?.min_balance && !minBalanceToken.loading + minBalanceTarget?.min_balance && minBalanceToken ? { denom: minBalanceTarget.denom, amount: convertMicroDenomToDenomWithDecimals( minBalanceTarget.min_balance, - minBalanceToken.data?.decimals ?? 0 + minBalanceToken.decimals ), } : undefined, @@ -559,121 +428,267 @@ export const makeConfigureRebalancerAction: ActionMaker< } } - const useTransformToCosmos: UseTransformToCosmos< - ConfigureRebalancerData - > = () => { - const queryClient = useQueryClient() - const whitelists = useQueryLoadingDataWithError( + async encode({ + valenceAccount, + chainId, + trustee, + baseDenom, + tokens, + pid, + maxLimit, + minBalance, + targetOverrideStrategy, + }: ConfigureRebalancerData): Promise { + if (!valenceAccount) { + throw new Error('Missing valence account.') + } + + const rebalancer = mustGetSupportedChainConfig(chainId).valence?.rebalancer + if (!rebalancer) { + throw new Error('Missing rebalancer address.') + } + + const whitelists = await this.options.queryClient.fetchQuery( valenceRebalancerExtraQueries.whitelistGenericTokens( - queryClient, - rebalancer - ? { - chainId, - address: rebalancer, - } - : undefined + this.options.queryClient, + { + chainId, + address: rebalancer, + } ) ) - return useCallback( - ({ - valenceAccount, - chainId, - trustee, - baseDenom, - tokens, - pid, - maxLimit, - minBalance, - targetOverrideStrategy, - }: ConfigureRebalancerData) => { - if (whitelists.loading) { - return - } else if (whitelists.errored) { - throw whitelists.error - } + return maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + chainId, + makeWasmMessage({ + wasm: { + execute: { + contract_addr: valenceAccount.address, + funds: [], + msg: { + // If rebalancer already exists, update it. Otherwise, + // register it. + [valenceAccount.config.rebalancer + ? 'update_service' + : 'register_to_service']: { + service_name: 'rebalancer', + data: encodeJsonToBase64({ + // Common options. + ...({ + base_denom: baseDenom, + // BPS + max_limit_bps: maxLimit && maxLimit * 100, + pid: { + p: pid.kp.toString(), + i: pid.ki.toString(), + d: pid.kd.toString(), + }, + target_override_strategy: targetOverrideStrategy, + targets: tokens.map(({ denom, percent }) => ({ + denom, + min_balance: + minBalance && minBalance.denom === denom + ? convertDenomToMicroDenomStringWithDecimals( + minBalance.amount, + // Should always find this. + whitelists.denoms.find( + (d) => d.denomOrAddress === denom + )?.decimals ?? 0 + ) + : undefined, + // BPS + bps: percent * 100, + })), + } as Pick< + RebalancerData, + keyof RebalancerData & keyof RebalancerUpdateData + >), + // Differences between data and update. + ...(valenceAccount.config.rebalancer + ? ({ + trustee: trustee ? { set: trustee } : 'clear', + } as Partial) + : ({ + trustee: trustee || null, + } as Partial)), + }), + }, + } as ValenceAccountExecuteMsg, + }, + }, + }) + ) + } - if (!valenceAccount) { - throw new Error('Missing valence account.') - } + async match([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): Promise { + let serviceName: string | undefined + let data: RebalancerData | RebalancerUpdateData | undefined + if ( + objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: {}, + }, + }, + }) + ) { + const serviceData = + 'register_to_service' in decodedMessage.wasm.execute.msg + ? decodedMessage.wasm.execute.msg.register_to_service + : 'update_service' in decodedMessage.wasm.execute.msg + ? decodedMessage.wasm.execute.msg.update_service + : undefined + if ( + objectMatchesStructure(serviceData, { + service_name: {}, + data: {}, + }) + ) { + serviceName = serviceData.service_name as string + data = JSON.parse(fromUtf8(fromBase64(serviceData.data as string))) + } + } - return maybeMakePolytoneExecuteMessage( - srcChainId, + if ( + serviceName !== 'rebalancer' || + !objectMatchesStructure(data, { + base_denom: {}, + targets: {}, + pid: {}, + target_override_strategy: {}, + }) || + !getSupportedChainConfig(chainId)?.valence?.rebalancer + ) { + return false + } + + return true + } + + async decode([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): Promise { + let data: RebalancerData | RebalancerUpdateData | undefined + if ( + objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: {}, + }, + }, + }) + ) { + const serviceData = + 'register_to_service' in decodedMessage.wasm.execute.msg + ? decodedMessage.wasm.execute.msg.register_to_service + : 'update_service' in decodedMessage.wasm.execute.msg + ? decodedMessage.wasm.execute.msg.update_service + : undefined + if ( + objectMatchesStructure(serviceData, { + service_name: {}, + data: {}, + }) + ) { + data = JSON.parse(fromUtf8(fromBase64(serviceData.data as string))) + } + } + + // Should never happen as this is checked in match. + if (!data) { + throw new Error('Missing data.') + } + + const rebalancer = mustGetSupportedChainConfig(chainId).valence?.rebalancer + // Should never happen as this is checked in match. + if (!rebalancer) { + throw new Error('Missing rebalancer address.') + } + + const whitelists = await this.options.queryClient.fetchQuery( + valenceRebalancerExtraQueries.whitelistGenericTokens( + this.options.queryClient, + { chainId, - makeWasmMessage({ - wasm: { - execute: { - contract_addr: valenceAccount.address, - funds: [], - msg: { - // If rebalancer already exists, update it. Otherwise, - // register it. - [valenceAccount.config.rebalancer - ? 'update_service' - : 'register_to_service']: { - service_name: 'rebalancer', - data: encodeJsonToBase64({ - // Common options. - ...({ - base_denom: baseDenom, - // BPS - max_limit_bps: maxLimit && maxLimit * 100, - pid: { - p: pid.kp.toString(), - i: pid.ki.toString(), - d: pid.kd.toString(), - }, - target_override_strategy: targetOverrideStrategy, - targets: tokens.map(({ denom, percent }) => ({ - denom, - min_balance: - minBalance && minBalance.denom === denom - ? convertDenomToMicroDenomStringWithDecimals( - minBalance.amount, - // Should always find this. - whitelists.data.denoms.find( - (d) => d.denomOrAddress === denom - )?.decimals ?? 0 - ) - : undefined, - // BPS - bps: percent * 100, - })), - } as Pick< - RebalancerData, - keyof RebalancerData & keyof RebalancerUpdateData - >), - // Differences between data and update. - ...(valenceAccount.config.rebalancer - ? ({ - trustee: trustee ? { set: trustee } : 'clear', - } as Partial) - : ({ - trustee: trustee || null, - } as Partial)), - }), - }, - } as ValenceAccountExecuteMsg, - }, - }, + address: rebalancer, + } + ) + ) + + const kp = Number(data.pid?.p || -1) + const ki = Number(data.pid?.i || -1) + const kd = Number(data.pid?.d || -1) + + // Show custom PID fields if no preset found for these settings. + const showCustomPid = !pidPresets.some( + (preset) => preset.kp === kp && preset.ki === ki && preset.kd === kd + ) + + // Get target with min balance set. + const minBalanceTarget = data.targets.find(({ min_balance }) => min_balance) + const minBalanceToken = minBalanceTarget + ? await this.options.queryClient.fetchQuery( + tokenQueries.info(this.options.queryClient, { + chainId, + type: TokenType.Native, + denomOrAddress: minBalanceTarget.denom, }) ) - }, - [whitelists] - ) - } + : undefined - return { - key: ActionKey.ConfigureRebalancer, - Icon: BalanceEmoji, - label: t('title.configureRebalancer'), - description: t('info.configureRebalancerDescription', { - context: context.type, - }), - notReusable: true, - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, + return { + chainId, + trustee: + typeof data.trustee === 'string' + ? 'update_service' in decodedMessage.wasm.execute.msg && + data.trustee === 'clear' + ? undefined + : data.trustee + : 'update_service' in decodedMessage.wasm.execute.msg && + typeof data.trustee === 'object' && + data.trustee && + 'set' in data.trustee + ? data.trustee.set + : undefined, + baseDenom: data.base_denom || whitelists.baseDenoms[0].denomOrAddress, + tokens: data.targets.map(({ denom, bps }) => ({ + denom, + percent: bps / 100, + })), + pid: { + kp, + ki, + kd, + }, + showCustomPid, + maxLimit: + typeof data.max_limit_bps === 'number' + ? data.max_limit_bps / 100 + : undefined, + minBalance: + minBalanceTarget?.min_balance && minBalanceToken + ? { + denom: minBalanceTarget.denom, + amount: convertMicroDenomToDenomWithDecimals( + minBalanceTarget.min_balance, + minBalanceToken.decimals + ), + } + : undefined, + targetOverrideStrategy: data.target_override_strategy || 'proportional', + } } } diff --git a/packages/stateful/actions/core/treasury/ConfigureVestingPayments/Component.tsx b/packages/stateful/actions/core/actions/ConfigureVestingPayments/Component.tsx similarity index 93% rename from packages/stateful/actions/core/treasury/ConfigureVestingPayments/Component.tsx rename to packages/stateful/actions/core/actions/ConfigureVestingPayments/Component.tsx index ff794a3fe..50a7ac91c 100644 --- a/packages/stateful/actions/core/treasury/ConfigureVestingPayments/Component.tsx +++ b/packages/stateful/actions/core/actions/ConfigureVestingPayments/Component.tsx @@ -1,10 +1,10 @@ import { useTranslation } from 'react-i18next' +import { useActionOptions } from '@dao-dao/stateless' import { VestingPaymentsWidgetData } from '@dao-dao/types' import { ActionComponent } from '@dao-dao/types/actions' import { VestingPaymentsEditor } from '../../../../widgets/widgets/VestingPayments/VestingPaymentsEditor' -import { useActionOptions } from '../../../react' export const ConfigureVestingPaymentsComponent: ActionComponent< undefined, diff --git a/packages/stateful/actions/core/treasury/ConfigureVestingPayments/README.md b/packages/stateful/actions/core/actions/ConfigureVestingPayments/README.md similarity index 100% rename from packages/stateful/actions/core/treasury/ConfigureVestingPayments/README.md rename to packages/stateful/actions/core/actions/ConfigureVestingPayments/README.md diff --git a/packages/stateful/actions/core/actions/ConfigureVestingPayments/index.ts b/packages/stateful/actions/core/actions/ConfigureVestingPayments/index.ts new file mode 100644 index 000000000..a807fd47f --- /dev/null +++ b/packages/stateful/actions/core/actions/ConfigureVestingPayments/index.ts @@ -0,0 +1,89 @@ +import cloneDeep from 'lodash.clonedeep' + +import { ActionBase, SuitAndTieEmoji } from '@dao-dao/stateless' +import { + UnifiedCosmosMsg, + VestingPaymentsWidgetData, + WidgetId, +} from '@dao-dao/types' +import { + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { getWidgetStorageItemKey } from '@dao-dao/utils' + +import { ManageWidgetsAction } from '../ManageWidgets' +import { ConfigureVestingPaymentsComponent } from './Component' + +export class ConfigureVestingPaymentsAction extends ActionBase { + public readonly key = ActionKey.ConfigureVestingPayments + public readonly Component = ConfigureVestingPaymentsComponent + + private manageWidgetsAction: ManageWidgetsAction + + constructor(options: ActionOptions) { + if (options.context.type !== ActionContextType.Dao) { + throw new Error('Not DAO context') + } + + const enabled = + !!options.context.dao.info.items[ + getWidgetStorageItemKey(WidgetId.VestingPayments) + ] + + super(options, { + Icon: SuitAndTieEmoji, + label: enabled + ? options.t('title.configureVestingPayments') + : options.t('title.enableVestingPayments'), + description: enabled + ? options.t('info.configureVestingPaymentsDescription') + : options.t('widgetDescription.vesting'), + keywords: ['payroll'], + notReusable: true, + }) + + this.manageWidgetsAction = new ManageWidgetsAction(options) + } + + async setup() { + await this.manageWidgetsAction.setup() + + // Attempt to load existing widget data. + const widget = this.manageWidgetsAction.availableWidgets.find( + ({ id }) => id === WidgetId.VestingPayments + ) + + this._defaults = widget + ? cloneDeep(widget.values) + : { + factories: {}, + } + } + + encode(data: VestingPaymentsWidgetData): UnifiedCosmosMsg { + return this.manageWidgetsAction.encode({ + mode: 'set', + id: WidgetId.VestingPayments, + values: data, + }) + } + + match(messages: ProcessedMessage[]): ActionMatch { + const manageWidgetsMatch = this.manageWidgetsAction.match(messages) + if (!manageWidgetsMatch) { + return manageWidgetsMatch + } + + // Ensure this is setting the vesting payments widget item. + const { mode, id } = this.manageWidgetsAction.decode(messages) + return mode === 'set' && id === WidgetId.VestingPayments + } + + decode(messages: ProcessedMessage[]): VestingPaymentsWidgetData { + return this.manageWidgetsAction.decode(messages).values + } +} diff --git a/packages/stateful/actions/core/dao_governance/CreateCrossChainAccount/Component.tsx b/packages/stateful/actions/core/actions/CreateCrossChainAccount/Component.tsx similarity index 96% rename from packages/stateful/actions/core/dao_governance/CreateCrossChainAccount/Component.tsx rename to packages/stateful/actions/core/actions/CreateCrossChainAccount/Component.tsx index fec9b5d41..412fc029f 100644 --- a/packages/stateful/actions/core/dao_governance/CreateCrossChainAccount/Component.tsx +++ b/packages/stateful/actions/core/actions/CreateCrossChainAccount/Component.tsx @@ -1,7 +1,11 @@ import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' -import { ChainPickerPopup, CopyToClipboard } from '@dao-dao/stateless' +import { + ChainPickerPopup, + CopyToClipboard, + useActionOptions, +} from '@dao-dao/stateless' import { ActionChainContextType, ActionComponent, @@ -9,8 +13,6 @@ import { } from '@dao-dao/types/actions' import { getDisplayNameForChainId, getImageUrlForChainId } from '@dao-dao/utils' -import { useActionOptions } from '../../../react' - export type CreateCrossChainAccountData = { chainId: string } diff --git a/packages/stateful/actions/core/dao_governance/CreateCrossChainAccount/README.md b/packages/stateful/actions/core/actions/CreateCrossChainAccount/README.md similarity index 100% rename from packages/stateful/actions/core/dao_governance/CreateCrossChainAccount/README.md rename to packages/stateful/actions/core/actions/CreateCrossChainAccount/README.md diff --git a/packages/stateful/actions/core/actions/CreateCrossChainAccount/index.tsx b/packages/stateful/actions/core/actions/CreateCrossChainAccount/index.tsx new file mode 100644 index 000000000..5379f0cf2 --- /dev/null +++ b/packages/stateful/actions/core/actions/CreateCrossChainAccount/index.tsx @@ -0,0 +1,74 @@ +import { ActionBase, ChainEmoji } from '@dao-dao/stateless' +import { + AccountType, + ActionChainContextType, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, + UnifiedCosmosMsg, +} from '@dao-dao/types' +import { maybeMakePolytoneExecuteMessages } from '@dao-dao/utils' + +import { + CreateCrossChainAccountComponent as Component, + CreateCrossChainAccountData, +} from './Component' + +export class CreateCrossChainAccountAction extends ActionBase { + public readonly key = ActionKey.CreateCrossChainAccount + public readonly Component = Component + + constructor(options: ActionOptions) { + // Only allow using this action in DAOs. + if ( + options.context.type !== ActionContextType.Dao || + // Type-check. If this is a DAO, it must be on a supported chain. + options.chainContext.type !== ActionChainContextType.Supported + ) { + throw new Error('Cannot create cross-chain account in this context.') + } + + const dao = options.context.dao + const missingChainIds = Object.keys( + options.chainContext.config.polytone || {} + ).filter((chainId) => !(chainId in dao.info.polytoneProxies)) + + super(options, { + Icon: ChainEmoji, + label: options.t('title.createCrossChainAccount'), + description: options.t('info.createCrossChainAccountDescription'), + }) + + this.defaults = { + chainId: missingChainIds[0] || '', + } + } + + encode({ chainId }: CreateCrossChainAccountData): UnifiedCosmosMsg[] { + return maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + chainId + ) + } + + match([ + { + wrappedMessages, + account: { type }, + }, + ]: ProcessedMessage[]): ActionMatch { + return type === AccountType.Polytone && wrappedMessages.length === 0 + } + + decode([ + { + account: { chainId }, + }, + ]: ProcessedMessage[]): CreateCrossChainAccountData { + return { + chainId, + } + } +} diff --git a/packages/stateful/actions/core/dao_governance/CreateDao/Component.tsx b/packages/stateful/actions/core/actions/CreateDao/Component.tsx similarity index 100% rename from packages/stateful/actions/core/dao_governance/CreateDao/Component.tsx rename to packages/stateful/actions/core/actions/CreateDao/Component.tsx diff --git a/packages/stateful/actions/core/dao_governance/CreateDao/README.md b/packages/stateful/actions/core/actions/CreateDao/README.md similarity index 100% rename from packages/stateful/actions/core/dao_governance/CreateDao/README.md rename to packages/stateful/actions/core/actions/CreateDao/README.md diff --git a/packages/stateful/actions/core/actions/CreateDao/index.tsx b/packages/stateful/actions/core/actions/CreateDao/index.tsx new file mode 100644 index 000000000..ef711fd98 --- /dev/null +++ b/packages/stateful/actions/core/actions/CreateDao/index.tsx @@ -0,0 +1,163 @@ +import { useQueryClient } from '@tanstack/react-query' + +import { ActionBase, DaoEmoji, useChain } from '@dao-dao/stateless' +import { + ActionComponent, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types' +import { decodeJsonFromBase64, objectMatchesStructure } from '@dao-dao/utils' + +import { LinkWrapper } from '../../../../components' +import { useQueryLoadingDataWithError } from '../../../../hooks' +import { daoQueries } from '../../../../queries' +import { CreateDaoComponent, CreateDaoData } from './Component' + +const Component: ActionComponent = (props) => { + const { chain_id: chainId } = useChain() + + // If admin is set, attempt to load parent DAO info. + const parentDao = useQueryLoadingDataWithError( + daoQueries.parentInfo( + useQueryClient(), + props.data.admin + ? { + chainId, + parentAddress: props.data.admin, + } + : undefined + ) + ) + + return ( + + ) +} + +export class CreateDaoAction extends ActionBase { + public readonly key = ActionKey.CreateCrossChainAccount + public readonly Component = Component + + protected _defaults: CreateDaoData = { + name: '', + description: '', + imageUrl: '', + } + + constructor(options: ActionOptions) { + super(options, { + Icon: DaoEmoji, + label: options.t('title.createDao'), + description: options.t('info.createDaoActionDescription'), + // Only use for rendering. + hideFromPicker: true, + }) + } + + // Only used for rendering. + encode() { + return [] + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + // Normal DAO creation via self-admin factory. + if ( + objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + instantiate_contract_with_self_admin: { + code_id: {}, + instantiate_msg: {}, + label: {}, + }, + }, + }, + }, + }) + ) { + const decoded = decodeJsonFromBase64( + decodedMessage.wasm.execute.msg.instantiate_contract_with_self_admin + .instantiate_msg + ) + + return objectMatchesStructure(decoded, { + admin: {}, + automatically_add_cw20s: {}, + automatically_add_cw721s: {}, + name: {}, + description: {}, + image_url: {}, + proposal_modules_instantiate_info: {}, + voting_module_instantiate_info: {}, + }) + } + + // SubDAO creation with parent DAO as admin. + return objectMatchesStructure(decodedMessage, { + wasm: { + instantiate: { + code_id: {}, + funds: {}, + label: {}, + msg: { + admin: {}, + automatically_add_cw20s: {}, + automatically_add_cw721s: {}, + name: {}, + description: {}, + image_url: {}, + proposal_modules_instantiate_info: {}, + voting_module_instantiate_info: {}, + }, + }, + }, + }) + } + + decode([{ decodedMessage }]: ProcessedMessage[]): CreateDaoData { + // Normal DAO creation via self-admin factory. + if ( + objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + msg: { + instantiate_contract_with_self_admin: {}, + }, + }, + }, + }) + ) { + const decoded = decodeJsonFromBase64( + decodedMessage.wasm.execute.msg.instantiate_contract_with_self_admin + .instantiate_msg + ) + + return { + admin: decoded.admin, + name: decoded.name, + description: decoded.description, + imageUrl: decoded.image_url, + } + } + + // SubDAO creation with parent DAO as admin. + return { + admin: decodedMessage.wasm.instantiate.msg.admin, + name: decodedMessage.wasm.instantiate.msg.name, + description: decodedMessage.wasm.instantiate.msg.description, + imageUrl: decodedMessage.wasm.instantiate.msg.image_url, + } + } +} diff --git a/packages/stateful/actions/core/advanced/CreateIca/Component.tsx b/packages/stateful/actions/core/actions/CreateIca/Component.tsx similarity index 76% rename from packages/stateful/actions/core/advanced/CreateIca/Component.tsx rename to packages/stateful/actions/core/actions/CreateIca/Component.tsx index c171b9a74..dd6fda5e3 100644 --- a/packages/stateful/actions/core/advanced/CreateIca/Component.tsx +++ b/packages/stateful/actions/core/actions/CreateIca/Component.tsx @@ -9,6 +9,7 @@ import { InputErrorMessage, Loader, StatusCard, + useActionOptions, useChain, } from '@dao-dao/stateless' import { LoadingDataWithError } from '@dao-dao/types' @@ -18,13 +19,7 @@ import { ActionContextType, ActionKey, } from '@dao-dao/types/actions' -import { - getDisplayNameForChainId, - getImageUrlForChainId, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { useActionOptions } from '../../../react' +import { getDisplayNameForChainId, getImageUrlForChainId } from '@dao-dao/utils' export type CreateIcaData = { chainId: string @@ -41,7 +36,6 @@ export const CreateIcaComponent: ActionComponent = ({ errors, options: { createdAddressLoading, icaHostSupported }, addAction, - allActionsWithData, remove, index, }) => { @@ -54,20 +48,6 @@ export const CreateIcaComponent: ActionComponent = ({ const imageUrl = destinationChainId && getImageUrlForChainId(destinationChainId) - const registerActionExists = - isCreating && - !!destinationChainId && - allActionsWithData.some( - ({ actionKey, data }) => - actionKey === ActionKey.ManageIcas && - objectMatchesStructure(data, { - chainId: {}, - register: {}, - }) && - data.chainId === destinationChainId && - data.register - ) - // Exclude polytone chain IDs and encourage them to use polytone instead. const polytoneChainIds = chainContext.type === ActionChainContextType.Supported @@ -139,31 +119,6 @@ export const CreateIcaComponent: ActionComponent = ({ - - {addAction && context.type === ActionContextType.Dao && ( -
-

- {t('info.createIcaRegister')} -

- - -
- )} )} diff --git a/packages/stateful/actions/core/advanced/CreateIca/README.md b/packages/stateful/actions/core/actions/CreateIca/README.md similarity index 100% rename from packages/stateful/actions/core/advanced/CreateIca/README.md rename to packages/stateful/actions/core/actions/CreateIca/README.md diff --git a/packages/stateful/actions/core/actions/CreateIca/index.tsx b/packages/stateful/actions/core/actions/CreateIca/index.tsx new file mode 100644 index 000000000..70566fed7 --- /dev/null +++ b/packages/stateful/actions/core/actions/CreateIca/index.tsx @@ -0,0 +1,254 @@ +import { useEffect } from 'react' +import { useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' + +import { chainQueries } from '@dao-dao/state/query' +import { + chainSupportsIcaHostSelector, + icaRemoteAddressSelector, +} from '@dao-dao/state/recoil' +import { + ActionBase, + ChainEmoji, + useActionOptions, + useCachedLoadingWithError, +} from '@dao-dao/stateless' +import { + ActionComponent, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, + UnifiedCosmosMsg, + makeStargateMessage, +} from '@dao-dao/types' +import { MsgRegisterInterchainAccount } from '@dao-dao/types/protobuf/codegen/ibc/applications/interchain_accounts/controller/v1/tx' +import { Metadata } from '@dao-dao/types/protobuf/codegen/ibc/applications/interchain_accounts/v1/metadata' +import { + ICA_CHAINS_TX_PREFIX, + getChainForChainName, + getDisplayNameForChainId, + getIbcTransferInfoBetweenChains, + getIbcTransferInfoFromConnection, + isDecodedStargateMsg, +} from '@dao-dao/utils' + +import { ManageStorageItemsAction } from '../ManageStorageItems' +import { CreateIcaComponent, CreateIcaData } from './Component' + +const Component: ActionComponent = (props) => { + const { t } = useTranslation() + const { + address, + chain: { chain_id: srcChainId }, + } = useActionOptions() + + const { watch, setError, clearErrors } = useFormContext() + const destChainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') + + const createdAddressLoading = useCachedLoadingWithError( + icaRemoteAddressSelector({ + address, + srcChainId, + destChainId, + }) + ) + const icaHostSupported = useCachedLoadingWithError( + chainSupportsIcaHostSelector({ + chainId: destChainId, + }) + ) + + // If ICA account already exists or ICA host not enabled for this chain during + // creation, add error preventing submission. + useEffect(() => { + if ( + destChainId && + !icaHostSupported.loading && + !icaHostSupported.updating && + (icaHostSupported.errored || !icaHostSupported.data) && + props.isCreating + ) { + setError((props.fieldNamePrefix + 'chainId') as 'chainId', { + type: 'manual', + message: icaHostSupported.errored + ? icaHostSupported.error.message + : t('error.icaHostUnsupported', { + chain: getDisplayNameForChainId(destChainId), + }), + }) + } else if ( + destChainId && + !createdAddressLoading.loading && + !createdAddressLoading.updating && + !createdAddressLoading.errored && + createdAddressLoading.data && + props.isCreating + ) { + setError((props.fieldNamePrefix + 'chainId') as 'chainId', { + type: 'manual', + message: t('error.icaAlreadyExists', { + chain: getDisplayNameForChainId(destChainId), + }), + }) + } else { + clearErrors((props.fieldNamePrefix + 'chainId') as 'chainId') + } + }, [ + clearErrors, + createdAddressLoading, + destChainId, + icaHostSupported, + props.fieldNamePrefix, + props.isCreating, + setError, + t, + ]) + + return ( + + ) +} + +export class CreateIcaAction extends ActionBase { + public readonly key = ActionKey.CreateIca + public readonly Component = Component + + private manageStorageItemsAction: ManageStorageItemsAction + + constructor(options: ActionOptions) { + super(options, { + Icon: ChainEmoji, + label: options.t('title.createIca'), + description: options.t('info.createIcaDescription'), + // Hide until ready. Update this in setup. + hideFromPicker: true, + }) + + this.manageStorageItemsAction = new ManageStorageItemsAction(options) + + this.defaults = { + chainId: '', + } + + // Fire async init immediately since we may hide this action. + this.init().catch(() => {}) + } + + async setup() { + // Hide from picker if chain does not support ICA controller. + this.metadata.hideFromPicker = !(await this.options.queryClient.fetchQuery( + chainQueries.supportsIcaController({ + chainId: this.options.chain.chain_id, + }) + )) + + return this.manageStorageItemsAction.setup() + } + + encode({ chainId }: CreateIcaData): UnifiedCosmosMsg[] { + if (!chainId) { + throw new Error('Missing chainId') + } + + const { sourceChain, destinationChain } = getIbcTransferInfoBetweenChains( + this.options.chain.chain_id, + chainId + ) + + return [ + makeStargateMessage({ + stargate: { + typeUrl: MsgRegisterInterchainAccount.typeUrl, + value: MsgRegisterInterchainAccount.fromPartial({ + owner: this.options.address, + connectionId: sourceChain.connection_id, + version: JSON.stringify( + Metadata.fromPartial({ + version: 'ics27-1', + controllerConnectionId: sourceChain.connection_id, + hostConnectionId: destinationChain.connection_id, + // Empty when registering a new address. + address: '', + encoding: 'proto3', + txType: 'sdk_multi_msg', + }) + ), + }), + }, + }), + this.manageStorageItemsAction.encode({ + setting: true, + key: ICA_CHAINS_TX_PREFIX + chainId, + value: '1', + }), + ] + } + + match(messages: ProcessedMessage[]): ActionMatch { + const icaRegistrationInfo = + isDecodedStargateMsg( + messages[0].decodedMessage, + MsgRegisterInterchainAccount, + { + connectionId: {}, + } + ) && + getIbcTransferInfoFromConnection( + this.options.chain.chain_id, + messages[0].decodedMessage.stargate.value.connectionId + ) + + if (!icaRegistrationInfo) { + return false + } + + // If there is a second message, check if it's a manage storage items that + // sets the ICA item for the destination chain. If so, match both messages. + // Otherwise, just match the first message. + if ( + messages.length >= 2 && + this.manageStorageItemsAction.match([messages[1]]) + ) { + const { setting, key, value } = this.manageStorageItemsAction.decode([ + messages[1], + ]) + return setting && + key === + ICA_CHAINS_TX_PREFIX + + getChainForChainName( + icaRegistrationInfo.destinationChain.chain_name + ).chain_id && + value === '1' + ? // Both ICA registration and item storage. + 2 + : // Only ICA registration. + 1 + } + + // Just match the one ICA registration message. This is for backwards + // compatibility, in case someone registered an ICA without storing it in an + // item using the old version of this action. Now it will always store the + // ICA in an item. + return 1 + } + + decode([{ decodedMessage }]: ProcessedMessage[]): CreateIcaData { + const { + destinationChain: { chain_name }, + } = getIbcTransferInfoFromConnection( + this.options.chain.chain_id, + decodedMessage.stargate.value.connectionId + ) + + return { + chainId: getChainForChainName(chain_name).chain_id, + } + } +} diff --git a/packages/stateful/actions/core/nfts/CreateNftCollection/README.md b/packages/stateful/actions/core/actions/CreateNftCollection/README.md similarity index 100% rename from packages/stateful/actions/core/nfts/CreateNftCollection/README.md rename to packages/stateful/actions/core/actions/CreateNftCollection/README.md diff --git a/packages/stateful/actions/core/actions/CreateNftCollection/index.tsx b/packages/stateful/actions/core/actions/CreateNftCollection/index.tsx new file mode 100644 index 000000000..5e9750a71 --- /dev/null +++ b/packages/stateful/actions/core/actions/CreateNftCollection/index.tsx @@ -0,0 +1,153 @@ +import { + ActionBase, + ArtistPaletteEmoji, + DaoSupportedChainPickerInput, + useActionOptions, +} from '@dao-dao/stateless' +import { ChainId, UnifiedCosmosMsg } from '@dao-dao/types' +import { + ActionChainContextType, + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { + getChainAddressForActionOptions, + getSupportedChainConfig, + makeWasmMessage, + maybeMakePolytoneExecuteMessages, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { + CreateNftCollectionActionData, + CreateNftCollectionAction as CreateNftCollectionComponent, +} from '../../../../components' + +const Component: ActionComponent = (props) => { + const { context } = useActionOptions() + + return ( + <> + {context.type === ActionContextType.Dao && ( + + )} + + + + ) +} + +export class CreateNftCollectionAction extends ActionBase { + public readonly key = ActionKey.CreateNftCollection + public readonly Component = Component + + constructor(options: ActionOptions) { + // Need to be on a supported chain to create an NFT collection. + if ( + options.chainContext.type !== ActionChainContextType.Supported || + !options.chainContext.config.codeIds.Cw721Base + ) { + throw new Error( + 'Creating NFT collections on this chain is not supported.' + ) + } + + super(options, { + Icon: ArtistPaletteEmoji, + label: options.t('title.createNftCollection'), + description: options.t('info.createNftCollectionDescription', { + context: options.context.type, + }), + }) + + this.defaults = { + chainId: options.chain.chain_id, + name: '', + symbol: '', + } + } + + encode({ + chainId, + name, + symbol, + }: CreateNftCollectionActionData): UnifiedCosmosMsg[] { + if ( + chainId === ChainId.StargazeMainnet || + chainId === ChainId.StargazeTestnet + ) { + throw new Error( + this.options.t('error.cannotUseCreateNftCollectionOnStargaze') + ) + } + + const creator = getChainAddressForActionOptions(this.options, chainId) + if (!creator) { + throw new Error('Creator address not found for this chain.') + } + + const codeId = getSupportedChainConfig(chainId)?.codeIds.Cw721Base + if (!codeId) { + throw new Error('NFT code ID not found for this chain.') + } + + return maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + chainId, + makeWasmMessage({ + wasm: { + instantiate: { + admin: creator, + code_id: codeId, + funds: [], + label: name, + msg: { + minter: creator, + name, + symbol, + }, + }, + }, + }) + ) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return objectMatchesStructure(decodedMessage, { + wasm: { + instantiate: { + code_id: {}, + label: {}, + msg: { + minter: {}, + name: {}, + symbol: {}, + }, + funds: {}, + }, + }, + }) + } + + decode([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): CreateNftCollectionActionData { + return { + chainId, + name: decodedMessage.wasm.instantiate.name, + symbol: decodedMessage.wasm.instantiate.symbol, + } + } +} diff --git a/packages/stateful/actions/core/valence/CreateValenceAccount/Component.tsx b/packages/stateful/actions/core/actions/CreateValenceAccount/Component.tsx similarity index 100% rename from packages/stateful/actions/core/valence/CreateValenceAccount/Component.tsx rename to packages/stateful/actions/core/actions/CreateValenceAccount/Component.tsx diff --git a/packages/stateful/actions/core/valence/CreateValenceAccount/README.md b/packages/stateful/actions/core/actions/CreateValenceAccount/README.md similarity index 100% rename from packages/stateful/actions/core/valence/CreateValenceAccount/README.md rename to packages/stateful/actions/core/actions/CreateValenceAccount/README.md diff --git a/packages/stateful/actions/core/actions/CreateValenceAccount/index.tsx b/packages/stateful/actions/core/actions/CreateValenceAccount/index.tsx new file mode 100644 index 000000000..77b152478 --- /dev/null +++ b/packages/stateful/actions/core/actions/CreateValenceAccount/index.tsx @@ -0,0 +1,298 @@ +import { fromUtf8, toUtf8 } from '@cosmjs/encoding' +import { useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' +import { useFormContext } from 'react-hook-form' + +import { + tokenQueries, + valenceRebalancerExtraQueries, +} from '@dao-dao/state/query' +import { + ActionBase, + AtomEmoji, + ChainProvider, + DaoSupportedChainPickerInput, + useActionOptions, +} from '@dao-dao/stateless' +import { + TokenType, + UnifiedCosmosMsg, + makeStargateMessage, +} from '@dao-dao/types' +import { + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { InstantiateMsg as ValenceAccountInstantiateMsg } from '@dao-dao/types/contracts/ValenceAccount' +import { MsgInstantiateContract2 } from '@dao-dao/types/protobuf/codegen/cosmwasm/wasm/v1/tx' +import { + VALENCE_INSTANTIATE2_SALT, + VALENCE_SUPPORTED_CHAINS, + convertDenomToMicroDenomStringWithDecimals, + convertMicroDenomToDenomWithDecimals, + getChainAddressForActionOptions, + getDisplayNameForChainId, + getSupportedChainConfig, + isDecodedStargateMsg, + maybeMakePolytoneExecuteMessages, + mustGetSupportedChainConfig, + tokensEqual, +} from '@dao-dao/utils' + +import { useQueryLoadingDataWithError } from '../../../../hooks' +import { useTokenBalances } from '../../../hooks' +import { + CreateValenceAccountComponent, + CreateValenceAccountData, +} from './Component' + +const Component: ActionComponent = (props) => { + const { context } = useActionOptions() + const { watch, setValue } = useFormContext() + + const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') + const funds = watch((props.fieldNamePrefix + 'funds') as 'funds') + + const nativeBalances = useTokenBalances({ + filter: TokenType.Native, + // Load selected tokens when not creating in case they are no longer + // returned in the list of all tokens for the given DAO/wallet after the + // proposal is made. + additionalTokens: props.isCreating + ? undefined + : funds.map(({ denom }) => ({ + chainId, + type: TokenType.Native, + denomOrAddress: denom, + })), + }) + + const queryClient = useQueryClient() + const rebalancer = mustGetSupportedChainConfig(chainId).valence?.rebalancer + const serviceFee = useQueryLoadingDataWithError( + valenceRebalancerExtraQueries.rebalancerRegistrationServiceFee( + queryClient, + rebalancer + ? { + chainId, + address: rebalancer, + } + : undefined + ) + ) + useEffect(() => { + setValue( + (props.fieldNamePrefix + 'serviceFee') as 'serviceFee', + serviceFee.loading || + serviceFee.errored || + serviceFee.updating || + !serviceFee.data + ? undefined + : { + amount: serviceFee.data.balance, + denom: serviceFee.data.token.denomOrAddress, + } + ) + }, [props.fieldNamePrefix, serviceFee, setValue]) + + return ( + <> + {context.type === ActionContextType.Dao && + VALENCE_SUPPORTED_CHAINS.length > 1 && ( + { + // Reset funds when switching chain. + setValue((props.fieldNamePrefix + 'funds') as 'funds', []) + }} + /> + )} + + + { + // Subtract service fee from balance for corresponding + // token to ensure that they leave enough for the fee. + // This value is used as the input max. + let balance = + !serviceFee.errored && + serviceFee.data && + tokensEqual(data.token, serviceFee.data.token) + ? BigInt(_balance) - BigInt(serviceFee.data.balance) + : BigInt(_balance) + if (balance < 0n) { + balance = 0n + } + + return { + ...data, + balance: balance.toString(), + } + } + ), + }, + serviceFee, + }} + /> + + + ) +} + +export class CreateValenceAccountAction extends ActionBase { + public readonly key = ActionKey.CreateValenceAccount + public readonly Component = Component + + protected _defaults: CreateValenceAccountData = { + chainId: VALENCE_SUPPORTED_CHAINS[0], + funds: [ + { + denom: 'untrn', + amount: 10, + decimals: 6, + }, + ], + } + + constructor(options: ActionOptions) { + super(options, { + Icon: AtomEmoji, + label: options.t('title.createValenceAccount'), + description: options.t('info.createValenceAccountDescription'), + notReusable: true, + // The configure rebalancer action is responsible for adding this action. + programmaticOnly: true, + }) + } + + encode({ + chainId, + funds, + serviceFee, + }: CreateValenceAccountData): UnifiedCosmosMsg[] { + const config = getSupportedChainConfig(chainId) + if (!config?.codeIds?.ValenceAccount || !config?.valence) { + throw new Error(this.options.t('error.unsupportedValenceChain')) + } + + const sender = getChainAddressForActionOptions(this.options, chainId) + if (!sender) { + throw new Error( + this.options.t('error.failedToFindChainAccount', { + chain: getDisplayNameForChainId(chainId), + }) + ) + } + + const convertedFunds = funds.map(({ denom, amount, decimals }) => ({ + denom, + amount: convertDenomToMicroDenomStringWithDecimals(amount, decimals), + })) + + // Add service fee to funds. + if (serviceFee && serviceFee.amount !== '0') { + const existing = convertedFunds.find((f) => f.denom === serviceFee.denom) + if (existing) { + existing.amount = ( + BigInt(existing.amount) + BigInt(serviceFee.amount) + ).toString() + } else { + convertedFunds.push({ + denom: serviceFee.denom, + amount: serviceFee.amount, + }) + } + } + + return maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + chainId, + makeStargateMessage({ + stargate: { + typeUrl: MsgInstantiateContract2.typeUrl, + value: { + sender, + admin: sender, + codeId: BigInt(config.codeIds.ValenceAccount), + label: 'Valence Account', + msg: toUtf8( + JSON.stringify({ + services_manager: config.valence.servicesManager, + } as ValenceAccountInstantiateMsg) + ), + funds: convertedFunds + // Neutron errors with `invalid coins` if the funds list is + // not alphabetized. + .sort((a, b) => a.denom.localeCompare(b.denom)), + salt: toUtf8(VALENCE_INSTANTIATE2_SALT), + fixMsg: false, + } as MsgInstantiateContract2, + }, + }) + ) + } + + match([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): ActionMatch { + return ( + !!getSupportedChainConfig(chainId)?.codeIds?.ValenceAccount && + isDecodedStargateMsg(decodedMessage, MsgInstantiateContract2) && + fromUtf8(decodedMessage.stargate.value.salt) === VALENCE_INSTANTIATE2_SALT + ) + } + + async decode([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): Promise { + const funds = await Promise.all( + (decodedMessage.stargate.value as MsgInstantiateContract2).funds.map( + async ({ denom, amount }) => { + const token = await this.options.queryClient.fetchQuery( + tokenQueries.info(this.options.queryClient, { + chainId, + type: TokenType.Native, + denomOrAddress: denom, + }) + ) + + return { + denom, + amount: convertMicroDenomToDenomWithDecimals( + amount, + token.decimals + ), + decimals: token.decimals, + } + } + ) + ) + + return { + chainId, + funds, + } + } +} diff --git a/packages/stateful/actions/core/advanced/CrossChainExecute/Component.tsx b/packages/stateful/actions/core/actions/CrossChainExecute/Component.tsx similarity index 100% rename from packages/stateful/actions/core/advanced/CrossChainExecute/Component.tsx rename to packages/stateful/actions/core/actions/CrossChainExecute/Component.tsx diff --git a/packages/stateful/actions/core/advanced/CrossChainExecute/README.md b/packages/stateful/actions/core/actions/CrossChainExecute/README.md similarity index 100% rename from packages/stateful/actions/core/advanced/CrossChainExecute/README.md rename to packages/stateful/actions/core/actions/CrossChainExecute/README.md diff --git a/packages/stateful/actions/core/actions/CrossChainExecute/index.tsx b/packages/stateful/actions/core/actions/CrossChainExecute/index.tsx new file mode 100644 index 000000000..be8c55c71 --- /dev/null +++ b/packages/stateful/actions/core/actions/CrossChainExecute/index.tsx @@ -0,0 +1,156 @@ +import { useFormContext } from 'react-hook-form' + +import { + ChainProvider, + DaoSupportedChainPickerInput, + TelescopeEmoji, + useActionOptions, + useChain, +} from '@dao-dao/stateless' +import { ActionBase } from '@dao-dao/stateless/actions' +import { + AccountType, + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, + UnifiedCosmosMsg, +} from '@dao-dao/types' +import { + getChainAddressForActionOptions, + maybeMakePolytoneExecuteMessages, +} from '@dao-dao/utils' + +import { SuspenseLoader } from '../../../../components' +import { useActionEncodeContext } from '../../../context' +import { WalletActionsProvider } from '../../../providers/wallet' +import { + CrossChainExecuteData, + CrossChainExecuteComponent as StatelessCrossChainExecuteComponent, +} from './Component' + +const InnerComponentLoading: ActionComponent = (props) => ( + +) + +const InnerComponent: ActionComponent = (props) => ( + +) + +const InnerComponentWrapper: ActionComponent = (props) => { + const { chain_id: chainId } = useChain() + + const options = useActionOptions() + const address = getChainAddressForActionOptions(options, chainId) + + return address ? ( + + + + ) : ( + + ) +} + +const Component: ActionComponent = (props) => { + const { + context, + chain: { chain_id: currentChainId }, + } = useActionOptions() + + const { watch } = useFormContext() + const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') + + return ( + <> + {context.type === ActionContextType.Dao && ( + + )} + + {chainId !== currentChainId && ( + // Re-render when chain changes so hooks and state reset. + + + + )} + + ) +} + +export class CrossChainExecuteAction extends ActionBase { + public readonly key = ActionKey.CrossChainExecute + public readonly Component = Component + + constructor(options: ActionOptions) { + super(options, { + Icon: TelescopeEmoji, + label: options.t('title.crossChainExecute'), + description: options.t('info.crossChainExecuteDescription'), + // Disallow creation if no Polytone accounts exist. + hideFromPicker: !options.context.accounts.some( + (a) => a.type === AccountType.Polytone + ), + // This is a more specific execute action, so it must be before execute, + // and many other actions integrate cross-chain functionality directly, so + // it should be after all the other ones. + matchPriority: -98, + }) + + this.defaults = { + chainId: options.chain.chain_id, + msgs: [], + } + } + + encode({ chainId, msgs }: CrossChainExecuteData): UnifiedCosmosMsg[] { + if (this.options.chain.chain_id === chainId) { + throw new Error('Cannot execute on the same chain') + } + + return maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + chainId, + msgs + ) + } + + match([ + { + decodedMessages, + account: { type }, + }, + ]: ProcessedMessage[]): ActionMatch { + return type === AccountType.Polytone && decodedMessages.length > 0 + } + + decode([ + { + wrappedMessages, + account: { chainId }, + }, + ]: ProcessedMessage[]): CrossChainExecuteData { + return { + chainId, + msgs: wrappedMessages.map(({ message }) => message), + } + } +} diff --git a/packages/stateful/actions/core/advanced/Custom/Component.stories.tsx b/packages/stateful/actions/core/actions/Custom/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/advanced/Custom/Component.stories.tsx rename to packages/stateful/actions/core/actions/Custom/Component.stories.tsx diff --git a/packages/stateful/actions/core/advanced/Custom/Component.tsx b/packages/stateful/actions/core/actions/Custom/Component.tsx similarity index 60% rename from packages/stateful/actions/core/advanced/Custom/Component.tsx rename to packages/stateful/actions/core/actions/Custom/Component.tsx index 2817eb245..60be98fa9 100644 --- a/packages/stateful/actions/core/advanced/Custom/Component.tsx +++ b/packages/stateful/actions/core/actions/Custom/Component.tsx @@ -1,17 +1,19 @@ import { Check, Close } from '@mui/icons-material' -import { useMemo } from 'react' +import JSON5 from 'json5' +import { useMemo, useState } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { CodeMirrorInput, FilterableItemPopup, + RawActionsRendererMessages, useChain, } from '@dao-dao/stateless' -import { ChainId, getProtobufTypes } from '@dao-dao/types' +import { getProtobufTypes } from '@dao-dao/types' import { ActionComponent } from '@dao-dao/types/actions' import { - convertJsonToCWCosmosMsg, + makeCosmosMsg, objectMatchesStructure, validateCosmosMsgForChain, } from '@dao-dao/utils' @@ -26,61 +28,61 @@ export const CustomComponent: ActionComponent = ({ isCreating, }) => { const { t } = useTranslation() - const { control, setValue } = useFormContext() + const { control, setValue, watch } = useFormContext() const { chain_id: chainId } = useChain() - const types = useMemo( - () => - getProtobufTypes().filter( - ([type]) => - // Only show protobuf message types. - type.split('.').pop()?.startsWith('Msg') && - // Only show stargaze message types on Stargaze chains. - (!type.startsWith('/publicawesome.stargaze') || - chainId === ChainId.StargazeMainnet || - chainId === ChainId.StargazeTestnet) - ), - [chainId] - ) + const message = watch((fieldNamePrefix + 'message') as 'message') + // Parse message for display if not creating. + const rawMessages = useMemo(() => { + if (isCreating) { + return [] + } + try { + const parsed = JSON5.parse(message) + return Array.isArray(parsed) ? parsed : [parsed] + } catch { + return [message] + } + }, [isCreating, message]) - return ( + const [types] = useState(getProtobufTypes) + + return isCreating ? ( <> - {isCreating && ( - ({ - key, - label: key, - type, - }))} - labelClassName="break-words whitespace-normal" - onSelect={({ key, type }) => - setValue( - (fieldNamePrefix + 'message') as 'message', - JSON.stringify( - { - stargate: { - typeUrl: key, - // Decoding empty data returns default. - value: type.decode(new Uint8Array()), - }, + ({ + key, + label: key, + type, + }))} + labelClassName="break-words whitespace-normal" + onSelect={({ key, type }) => + setValue( + (fieldNamePrefix + 'message') as 'message', + JSON.stringify( + { + stargate: { + typeUrl: key, + // Decoding empty data returns default. + value: type.decode(new Uint8Array()), }, - null, - 2 - ) + }, + null, + 2 ) - } - searchPlaceholder={t('info.searchMessages')} - trigger={{ - type: 'button', - props: { - className: 'self-start', - variant: 'secondary', - children: t('button.loadMessageTemplate'), - }, - }} - /> - )} + ) + } + searchPlaceholder={t('info.searchMessages')} + trigger={{ + type: 'button', + props: { + className: 'self-start', + variant: 'secondary', + children: t('button.loadMessageTemplate'), + }, + }} + /> { try { - validateCosmosMsgForChain( - chainId, - convertJsonToCWCosmosMsg(value) + const parsed = JSON5.parse(value) + const msgs = Array.isArray(parsed) ? parsed : [parsed] + + msgs.forEach((msg) => + validateCosmosMsgForChain(chainId, makeCosmosMsg(msg)) ) } catch (err) { return err instanceof Error ? err.message : `${err}` @@ -127,6 +131,8 @@ export const CustomComponent: ActionComponent = ({

)} + ) : ( + ) } diff --git a/packages/stateful/actions/core/advanced/Custom/README.md b/packages/stateful/actions/core/actions/Custom/README.md similarity index 100% rename from packages/stateful/actions/core/advanced/Custom/README.md rename to packages/stateful/actions/core/actions/Custom/README.md diff --git a/packages/stateful/actions/core/actions/Custom/index.ts b/packages/stateful/actions/core/actions/Custom/index.ts new file mode 100644 index 000000000..aec4520b3 --- /dev/null +++ b/packages/stateful/actions/core/actions/Custom/index.ts @@ -0,0 +1,55 @@ +import JSON5 from 'json5' + +import { ActionBase, RobotEmoji } from '@dao-dao/stateless' +import { UnifiedCosmosMsg } from '@dao-dao/types' +import { + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { makeCosmosMsg } from '@dao-dao/utils' + +import { CustomComponent, CustomData } from './Component' + +export class CustomAction extends ActionBase { + public readonly key = ActionKey.Custom + public readonly Component = CustomComponent + + protected _defaults: CustomData = { + message: '{}', + } + + constructor(options: ActionOptions) { + super(options, { + Icon: RobotEmoji, + label: options.t('title.custom'), + description: options.t('info.customActionDescription', { + context: options.context.type, + }), + // should be matched last since this matches any message + matchPriority: -100, + }) + } + + encode({ message }: CustomData): UnifiedCosmosMsg | UnifiedCosmosMsg[] { + const messageOrMessages = JSON5.parse(message) + if (Array.isArray(messageOrMessages)) { + return messageOrMessages.map((message) => makeCosmosMsg(message)) + } else { + return makeCosmosMsg(messageOrMessages) + } + } + + // all messages match the Custom action + match(): ActionMatch { + return true + } + + decode(messages: ProcessedMessage[]): CustomData { + const data = messages.map(({ message }) => message) + return { + message: JSON.stringify(data.length === 1 ? data[0] : data, undefined, 2), + } + } +} diff --git a/packages/stateful/actions/core/dao_governance/DaoAdminExec/Component.tsx b/packages/stateful/actions/core/actions/DaoAdminExec/Component.tsx similarity index 100% rename from packages/stateful/actions/core/dao_governance/DaoAdminExec/Component.tsx rename to packages/stateful/actions/core/actions/DaoAdminExec/Component.tsx diff --git a/packages/stateful/actions/core/dao_governance/DaoAdminExec/README.md b/packages/stateful/actions/core/actions/DaoAdminExec/README.md similarity index 100% rename from packages/stateful/actions/core/dao_governance/DaoAdminExec/README.md rename to packages/stateful/actions/core/actions/DaoAdminExec/README.md diff --git a/packages/stateful/actions/core/dao_governance/DaoAdminExec/index.tsx b/packages/stateful/actions/core/actions/DaoAdminExec/index.tsx similarity index 71% rename from packages/stateful/actions/core/dao_governance/DaoAdminExec/index.tsx rename to packages/stateful/actions/core/actions/DaoAdminExec/index.tsx index 370fd1434..094b0e5a0 100644 --- a/packages/stateful/actions/core/dao_governance/DaoAdminExec/index.tsx +++ b/packages/stateful/actions/core/actions/DaoAdminExec/index.tsx @@ -4,30 +4,31 @@ import { useTranslation } from 'react-i18next' import { daoQueries } from '@dao-dao/state' import { + ActionBase, ChainProvider, DaoSupportedChainPickerInput, InputLabel, JoystickEmoji, RadioInputNoForm, + useActionOptions, } from '@dao-dao/stateless' import { ActionComponent, ActionContextType, ActionKey, - ActionMaker, + ActionMatch, + ActionOptions, DaoSource, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, + ProcessedMessage, + UnifiedCosmosMsg, } from '@dao-dao/types' import { - decodePolytoneExecuteMsg, getChainAddressForActionOptions, isValidBech32Address, makeExecuteSmartContractMessage, makeValidateAddress, maybeGetChainForChainId, - maybeMakePolytoneExecuteMessage, + maybeMakePolytoneExecuteMessages, objectMatchesStructure, } from '@dao-dao/utils' @@ -38,52 +39,31 @@ import { SuspenseLoader, } from '../../../../components' import { useQueryLoadingDataWithError } from '../../../../hooks' -import { - useActionOptions, - useActionsForMatching, - useLoadedActionsAndCategories, -} from '../../../react' +import { useActionEncodeContext } from '../../../context' import { DaoAdminExecData, DaoAdminExecComponent as StatelessDaoAdminExecComponent, } from './Component' -const useDefaults: UseDefaults = () => ({ - chainId: useActionOptions().chain.chain_id, - coreAddress: '', - msgs: [], -}) - const InnerComponentLoading: ActionComponent = (props) => ( ) -const InnerComponent: ActionComponent = (props) => { - const { categories, loadedActions } = useLoadedActionsAndCategories({ - isCreating: props.isCreating, - }) - const actionsForMatching = useActionsForMatching() - - return ( - - ) -} +const InnerComponent: ActionComponent = (props) => ( + +) const Component: ActionComponent = (props) => { const { t } = useTranslation() @@ -106,7 +86,7 @@ const Component: ActionComponent = (props) => { const daoSubDaosLoading = useQueryLoadingDataWithError( context.type === ActionContextType.Dao ? daoQueries.listAllSubDaos(queryClient, { - chainId, + chainId: currentChainId, address, // We only care about the SubDAOs this DAO has admin powers over. onlyAdmin: true, @@ -230,52 +210,35 @@ const Component: ActionComponent = (props) => { ) } -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - let chainId = useActionOptions().chain.chain_id - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - } +export class DaoAdminExecAction extends ActionBase { + public readonly key = ActionKey.DaoAdminExec + public readonly Component = Component - return objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - execute_admin_msgs: { - msgs: {}, - }, - }, - }, - }, - }) - ? { - match: true, - data: { - chainId, - coreAddress: msg.wasm.execute.contract_addr, - msgs: msg.wasm.execute.msg.execute_admin_msgs.msgs, - }, - } - : { - match: false, - } -} + constructor(options: ActionOptions) { + super(options, { + Icon: JoystickEmoji, + label: options.t('title.daoAdminExec'), + description: options.t('info.daoAdminExecDescription'), + }) -const useTransformToCosmos: UseTransformToCosmos = () => { - const options = useActionOptions() + this.defaults = { + chainId: options.chain.chain_id, + coreAddress: '', + msgs: [], + } + } - return ({ chainId = options.chain.chain_id, coreAddress, msgs }) => - maybeMakePolytoneExecuteMessage( - options.chain.chain_id, + encode({ + chainId = this.options.chain.chain_id, + coreAddress, + msgs, + }: DaoAdminExecData): UnifiedCosmosMsg[] { + return maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, chainId, makeExecuteSmartContractMessage({ chainId, - sender: getChainAddressForActionOptions(options, chainId) || '', + sender: getChainAddressForActionOptions(this.options, chainId) || '', contractAddress: coreAddress, msg: { execute_admin_msgs: { @@ -284,17 +247,34 @@ const useTransformToCosmos: UseTransformToCosmos = () => { }, }) ) -} + } -export const makeDaoAdminExecAction: ActionMaker = ({ - t, -}) => ({ - key: ActionKey.DaoAdminExec, - Icon: JoystickEmoji, - label: t('title.daoAdminExec'), - description: t('info.daoAdminExecDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, -}) + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + execute_admin_msgs: { + msgs: {}, + }, + }, + }, + }, + }) + } + + decode([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): DaoAdminExecData { + return { + chainId, + coreAddress: decodedMessage.wasm.execute.contract_addr, + msgs: decodedMessage.wasm.execute.msg.execute_admin_msgs.msgs, + } + } +} diff --git a/packages/stateful/actions/core/dao_governance/EnableMultipleChoice/Component.tsx b/packages/stateful/actions/core/actions/EnableMultipleChoice/Component.tsx similarity index 100% rename from packages/stateful/actions/core/dao_governance/EnableMultipleChoice/Component.tsx rename to packages/stateful/actions/core/actions/EnableMultipleChoice/Component.tsx diff --git a/packages/stateful/actions/core/dao_governance/EnableMultipleChoice/README.md b/packages/stateful/actions/core/actions/EnableMultipleChoice/README.md similarity index 100% rename from packages/stateful/actions/core/dao_governance/EnableMultipleChoice/README.md rename to packages/stateful/actions/core/actions/EnableMultipleChoice/README.md diff --git a/packages/stateful/actions/core/actions/EnableMultipleChoice/index.tsx b/packages/stateful/actions/core/actions/EnableMultipleChoice/index.tsx new file mode 100644 index 000000000..4b655d3ee --- /dev/null +++ b/packages/stateful/actions/core/actions/EnableMultipleChoice/index.tsx @@ -0,0 +1,260 @@ +import { tokenQueries } from '@dao-dao/state/query' +import { ActionBase, NumbersEmoji } from '@dao-dao/stateless' +import { + ContractVersion, + DepositRefundPolicy, + Feature, + TokenType, + UnifiedCosmosMsg, +} from '@dao-dao/types' +import { + ActionChainContextType, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { PercentageThreshold } from '@dao-dao/types/contracts/DaoProposalMultiple' +import { Config as SingleChoiceConfig } from '@dao-dao/types/contracts/DaoProposalSingle.v2' +import { Config as SecretSingleChoiceConfig } from '@dao-dao/types/contracts/SecretDaoProposalSingle' +import { + ContractName, + DaoProposalMultipleAdapterId, + convertCosmosVetoConfigToVeto, + convertDurationToDurationWithUnits, + convertMicroDenomToDenomWithDecimals, + getNativeTokenForChainId, + makeExecuteSmartContractMessage, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { + SecretSingleChoiceProposalModule, + SingleChoiceProposalModule, +} from '../../../../clients' +import { DaoProposalMultipleAdapter } from '../../../../proposal-module-adapter' +import { makeDefaultNewDao } from '../../../../recoil' +import { EnableMultipleChoiceComponent } from './Component' + +export class EnableMultipleChoiceAction extends ActionBase<{}> { + public readonly key = ActionKey.EnableMultipleChoice + public readonly Component = EnableMultipleChoiceComponent + + protected _defaults = {} + + constructor(options: ActionOptions) { + // Disallow usage if: + // - not a DAO + // - DAO doesn't support multiple choice proposals + // - Neutron fork SubDAO + // - chain is not supported (type-check, implied by DAO check) + // + // Disallows creation via `hideWithPicker` (at the bottom) if: + // - multiple choice proposal module already exists + // - single-choice approval flow is enabled, since multiple choice doesn't + // support approval flow right now and that would be confusing. + if ( + options.context.type !== ActionContextType.Dao || + !options.context.dao.info.supportedFeatures[ + Feature.MultipleChoiceProposals + ] || + // Neutron fork SubDAOs don't support multiple choice proposals due to the + // timelock/overrule system only being designed for single choice + // proposals. + options.context.dao.coreVersion === ContractVersion.V2AlphaNeutronFork || + options.chainContext.type !== ActionChainContextType.Supported + ) { + throw new Error('Invalid context for enabling multiple choice proposals') + } + + super(options, { + Icon: NumbersEmoji, + label: options.t('title.enableMultipleChoiceProposals'), + description: options.t('info.enableMultipleChoiceProposalsDescription'), + notReusable: true, + // Disallow creation if: + // - multiple choice proposal module already exists + // - single-choice approval flow is enabled, since multiple choice doesn't + // support approval flow right now and that would be confusing. + hideFromPicker: options.context.dao.proposalModules.some( + ({ contractName, prePropose }) => + DaoProposalMultipleAdapter.contractNames.some((name) => + contractName.includes(name) + ) || + prePropose?.contractName === ContractName.PreProposeApprovalSingle + ), + }) + } + + async encode(): Promise { + // Type-check. This is already checked in the constructor. + if ( + this.options.context.type !== ActionContextType.Dao || + this.options.chainContext.type !== ActionChainContextType.Supported + ) { + throw new Error('Invalid context for enabling multiple choice proposals') + } + + const singleChoiceProposalModule = + this.options.context.dao.proposalModules.find( + (module) => + module instanceof SingleChoiceProposalModule || + module instanceof SecretSingleChoiceProposalModule + ) + if (!singleChoiceProposalModule) { + throw new Error('No single choice proposal module found') + } + + const [config, depositInfoWithToken] = await Promise.all([ + this.options.queryClient.fetchQuery< + SingleChoiceConfig | SecretSingleChoiceConfig + >( + // Type-cast since we know the module is either a single choice or + // secret single choice proposal module. + singleChoiceProposalModule.getConfigQuery() as any + ), + this.options.queryClient + .fetchQuery(singleChoiceProposalModule.getDepositInfoQuery()) + .then(async (depositInfo) => + depositInfo + ? { + depositInfo, + token: await this.options.queryClient.fetchQuery( + tokenQueries.info(this.options.queryClient, { + chainId: this.options.chain.chain_id, + type: + 'cw20' in depositInfo.denom + ? TokenType.Cw20 + : TokenType.Native, + denomOrAddress: + 'cw20' in depositInfo.denom + ? depositInfo.denom.cw20 + : depositInfo.denom.native, + }) + ), + } + : { + depositInfo, + } + ), + ]) + + const quorum: PercentageThreshold = + 'threshold_quorum' in config.threshold + ? config.threshold.threshold_quorum.quorum + : { + percent: '0.2', + } + + const newDao = makeDefaultNewDao(this.options.chain.chain_id) + const info = DaoProposalMultipleAdapter.daoCreation.getInstantiateInfo( + this.options.chainContext.config, + { + ...newDao, + votingConfig: { + ...newDao.votingConfig, + quorum: { + majority: 'majority' in quorum, + value: 'majority' in quorum ? 50 : Number(quorum.percent) * 100, + }, + votingDuration: convertDurationToDurationWithUnits( + config.max_voting_period + ), + proposalDeposit: { + enabled: !!depositInfoWithToken.depositInfo, + amount: depositInfoWithToken.depositInfo + ? convertMicroDenomToDenomWithDecimals( + depositInfoWithToken.depositInfo.amount, + depositInfoWithToken.token.decimals + ) + : 10, + type: + depositInfoWithToken.depositInfo && + 'cw20' in depositInfoWithToken.depositInfo.denom + ? 'cw20' + : 'native', + denomOrAddress: depositInfoWithToken.depositInfo + ? 'cw20' in depositInfoWithToken.depositInfo.denom + ? depositInfoWithToken.depositInfo.denom.cw20 + : depositInfoWithToken.depositInfo.denom.native + : getNativeTokenForChainId(this.options.chain.chain_id) + .denomOrAddress, + token: depositInfoWithToken.token, + refundPolicy: + depositInfoWithToken.depositInfo?.refund_policy ?? + DepositRefundPolicy.OnlyPassed, + }, + anyoneCanPropose: singleChoiceProposalModule.prePropose + ? 'anyone' in singleChoiceProposalModule.prePropose.submissionPolicy + : // If no pre-propose module, default to only members can propose. + false, + allowRevoting: config.allow_revoting, + approver: { + enabled: false, + address: '', + }, + veto: convertCosmosVetoConfigToVeto( + 'veto' in config ? config.veto : null + ), + }, + }, + { + ...makeDefaultNewDao(this.options.chain.chain_id).votingConfig, + enableMultipleChoice: true, + moduleInstantiateFundsUnsupported: + !this.options.context.dao.info.supportedFeatures[ + Feature.ModuleInstantiateFunds + ], + }, + this.options.t + ) + + return makeExecuteSmartContractMessage({ + chainId: this.options.chain.chain_id, + contractAddress: this.options.address, + sender: this.options.address, + msg: { + update_proposal_modules: { + to_add: [info], + to_disable: [], + }, + }, + }) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return ( + objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + msg: { + update_proposal_modules: { + to_add: [ + { + admin: {}, + code_id: {}, + label: {}, + msg: {}, + }, + ], + to_disable: [], + }, + }, + }, + }, + }) && + (decodedMessage.wasm.execute.msg.update_proposal_modules.to_add[0].label.startsWith( + 'dao-proposal-multiple' + ) || + // backwards compatibility + decodedMessage.wasm.execute.msg.update_proposal_modules.to_add[0].label.endsWith( + DaoProposalMultipleAdapterId + )) + ) + } + + decode() { + return {} + } +} diff --git a/packages/stateful/actions/core/treasury/EnableRetroactiveCompensation/Component.tsx b/packages/stateful/actions/core/actions/EnableRetroactiveCompensation/Component.tsx similarity index 100% rename from packages/stateful/actions/core/treasury/EnableRetroactiveCompensation/Component.tsx rename to packages/stateful/actions/core/actions/EnableRetroactiveCompensation/Component.tsx diff --git a/packages/stateful/actions/core/treasury/EnableRetroactiveCompensation/README.md b/packages/stateful/actions/core/actions/EnableRetroactiveCompensation/README.md similarity index 100% rename from packages/stateful/actions/core/treasury/EnableRetroactiveCompensation/README.md rename to packages/stateful/actions/core/actions/EnableRetroactiveCompensation/README.md diff --git a/packages/stateful/actions/core/actions/EnableRetroactiveCompensation/index.ts b/packages/stateful/actions/core/actions/EnableRetroactiveCompensation/index.ts new file mode 100644 index 000000000..876930c51 --- /dev/null +++ b/packages/stateful/actions/core/actions/EnableRetroactiveCompensation/index.ts @@ -0,0 +1,73 @@ +import { ActionBase, BeeEmoji } from '@dao-dao/stateless' +import { UnifiedCosmosMsg, WidgetId } from '@dao-dao/types' +import { + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { getWidgetStorageItemKey } from '@dao-dao/utils' + +import { ManageWidgetsAction } from '../ManageWidgets' +import { EnableRetroactiveCompensationComponent } from './Component' + +export class EnableRetroactiveCompensationAction extends ActionBase<{}> { + public readonly key = ActionKey.EnableRetroactiveCompensation + public readonly Component = EnableRetroactiveCompensationComponent + + protected _defaults = {} + + private manageWidgetsAction: ManageWidgetsAction + + constructor(options: ActionOptions) { + if (options.context.type !== ActionContextType.Dao) { + throw new Error('Not DAO context') + } + + const enabled = + !!options.context.dao.info.items[ + getWidgetStorageItemKey(WidgetId.RetroactiveCompensation) + ] + + super(options, { + Icon: BeeEmoji, + label: options.t('title.enableRetroactiveCompensation'), + description: options.t('widgetDescription.retroactive'), + keywords: ['payroll'], + notReusable: true, + // Do not allow using this action if the DAO already has retroactive + // compensation enabled. + hideFromPicker: enabled, + }) + + this.manageWidgetsAction = new ManageWidgetsAction(options) + } + + setup() { + return this.manageWidgetsAction.setup() + } + + encode(): UnifiedCosmosMsg { + return this.manageWidgetsAction.encode({ + mode: 'set', + id: WidgetId.RetroactiveCompensation, + values: {}, + }) + } + + match(messages: ProcessedMessage[]): ActionMatch { + const manageWidgetsMatch = this.manageWidgetsAction.match(messages) + if (!manageWidgetsMatch) { + return manageWidgetsMatch + } + + // Ensure this is setting the retroactive compensation widget item. + const { mode, id } = this.manageWidgetsAction.decode(messages) + return mode === 'set' && id === WidgetId.RetroactiveCompensation + } + + decode() { + return {} + } +} diff --git a/packages/stateful/actions/core/smart_contracting/Execute/Component.stories.tsx b/packages/stateful/actions/core/actions/Execute/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/smart_contracting/Execute/Component.stories.tsx rename to packages/stateful/actions/core/actions/Execute/Component.stories.tsx diff --git a/packages/stateful/actions/core/smart_contracting/Execute/Component.tsx b/packages/stateful/actions/core/actions/Execute/Component.tsx similarity index 96% rename from packages/stateful/actions/core/smart_contracting/Execute/Component.tsx rename to packages/stateful/actions/core/actions/Execute/Component.tsx index 0e8875003..6186c2b03 100644 --- a/packages/stateful/actions/core/smart_contracting/Execute/Component.tsx +++ b/packages/stateful/actions/core/actions/Execute/Component.tsx @@ -13,6 +13,7 @@ import { InputLabel, NativeCoinSelector, TokenInput, + useActionOptions, useChain, } from '@dao-dao/stateless' import { GenericTokenBalance, LoadingData, TokenType } from '@dao-dao/types' @@ -26,8 +27,6 @@ import { validateRequired, } from '@dao-dao/utils' -import { useActionOptions } from '../../../react' - export type ExecuteData = { chainId: string sender: string @@ -200,18 +199,15 @@ export const ExecuteComponent: ActionComponent = ({ 1, selectedCw20?.token.decimals ?? 0 ), - max: convertMicroDenomToDenomWithDecimals( - selectedCw20?.balance ?? 0, - selectedCw20?.token.decimals ?? 0 - ), step: convertMicroDenomToDenomWithDecimals( 1, selectedCw20?.token.decimals ?? 0 ), }} - onSelectToken={({ denomOrAddress }) => + onSelectToken={({ denomOrAddress, decimals }) => { setValue(fieldNamePrefix + 'funds.0.denom', denomOrAddress) - } + setValue(fieldNamePrefix + 'funds.0.decimals', decimals) + }} readOnly={!isCreating} selectedToken={selectedCw20?.token} tokens={ diff --git a/packages/stateful/actions/core/smart_contracting/Execute/README.md b/packages/stateful/actions/core/actions/Execute/README.md similarity index 100% rename from packages/stateful/actions/core/smart_contracting/Execute/README.md rename to packages/stateful/actions/core/actions/Execute/README.md diff --git a/packages/stateful/actions/core/actions/Execute/index.tsx b/packages/stateful/actions/core/actions/Execute/index.tsx new file mode 100644 index 000000000..b45147bbe --- /dev/null +++ b/packages/stateful/actions/core/actions/Execute/index.tsx @@ -0,0 +1,420 @@ +import { Coin } from '@cosmjs/stargate' +import JSON5 from 'json5' +import { useEffect } from 'react' +import { useFormContext } from 'react-hook-form' + +import { tokenQueries } from '@dao-dao/state/query' +import { + ActionBase, + ChainProvider, + DaoSupportedChainPickerInput, + SwordsEmoji, + useActionOptions, +} from '@dao-dao/stateless' +import { AccountType, TokenType, UnifiedCosmosMsg } from '@dao-dao/types' +import { + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { MsgExecuteContract as SecretMsgExecuteContract } from '@dao-dao/types/protobuf/codegen/secret/compute/v1beta1/msg' +import { + bech32DataToAddress, + convertDenomToMicroDenomStringWithDecimals, + convertMicroDenomToDenomWithDecimals, + decodeJsonFromBase64, + encodeJsonToBase64, + getAccountAddress, + isDecodedStargateMsg, + isSecretNetwork, + makeExecuteSmartContractMessage, + maybeMakeIcaExecuteMessages, + maybeMakePolytoneExecuteMessages, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { useTokenBalances } from '../../../hooks' +import { + ExecuteData, + ExecuteComponent as StatelessExecuteComponent, +} from './Component' + +// Account types that are allowed to execute from. +const ALLOWED_ACCOUNT_TYPES: readonly AccountType[] = [ + AccountType.Base, + AccountType.Polytone, + AccountType.Ica, +] + +const Component: ActionComponent = (props) => { + const { context } = useActionOptions() + const { watch, setValue } = useFormContext() + + const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') + const sender = watch((props.fieldNamePrefix + 'sender') as 'sender') + const funds = watch((props.fieldNamePrefix + 'funds') as 'funds') + const cw20 = watch((props.fieldNamePrefix + 'cw20') as 'cw20') + + const tokens = useTokenBalances({ + // Load selected tokens when not creating in case they are no longer + // returned in the list of all tokens for the given DAO/wallet after the + // proposal is made. + additionalTokens: props.isCreating + ? undefined + : funds.map(({ denom }) => ({ + chainId, + type: cw20 ? TokenType.Cw20 : TokenType.Native, + denomOrAddress: denom, + })), + }) + + // If sender is not found in the list of accounts, reset to the first account + // on the target chain, or an empty account. + useEffect(() => { + if ( + sender && + !context.accounts.some( + (a) => a.chainId === chainId && a.address === sender + ) + ) { + setValue( + (props.fieldNamePrefix + 'sender') as 'sender', + getAccountAddress({ + accounts: context.accounts, + chainId, + types: ALLOWED_ACCOUNT_TYPES, + }) || '' + ) + } + }, [chainId, context.accounts, props.fieldNamePrefix, sender, setValue]) + + return ( + <> + {context.type === ActionContextType.Dao && ( + { + // Reset funds when switching chain. + setValue((props.fieldNamePrefix + 'funds') as 'funds', []) + // Default sender to first matching account on new chain. + setValue( + (props.fieldNamePrefix + 'sender') as 'sender', + getAccountAddress({ + accounts: context.accounts, + chainId, + types: ALLOWED_ACCOUNT_TYPES, + }) || '' + ) + }} + /> + )} + + + + token.chainId === chainId && owner.address === sender + ), + }, + }} + /> + + + ) +} + +export class ExecuteAction extends ActionBase { + public readonly key = ActionKey.Execute + public readonly Component = Component + + constructor(options: ActionOptions) { + super(options, { + Icon: SwordsEmoji, + label: options.t('title.executeSmartContract'), + description: options.t('info.executeSmartContractActionDescription'), + // Most messages are some form of execute, but it needs to be before + // Custom, since that's the catch-all action. + matchPriority: -99, + }) + + this.defaults = { + chainId: options.chain.chain_id, + sender: options.address, + address: '', + message: '{}', + funds: [], + cw20: false, + } + } + + encode({ + chainId, + sender, + address, + message, + funds, + cw20, + }: ExecuteData): UnifiedCosmosMsg | UnifiedCosmosMsg[] { + const account = this.options.context.accounts.find( + (a) => a.chainId === chainId && a.address === sender + ) + if (!account) { + throw new Error('Executor account not found') + } + + const msg = JSON5.parse(message) + + let executeMsg: UnifiedCosmosMsg | undefined + if (cw20) { + if (funds.length !== 1) { + throw new Error('Missing CW20 fund denom.') + } + + // Execute CW20 send message. + const isSecret = isSecretNetwork(chainId) + executeMsg = makeExecuteSmartContractMessage({ + chainId, + sender, + contractAddress: funds[0].denom, + msg: { + send: { + amount: convertDenomToMicroDenomStringWithDecimals( + funds[0].amount, + funds[0].decimals + ), + [isSecret ? 'recipient' : 'contract']: address, + msg: encodeJsonToBase64(msg), + ...(isSecret && { + padding: '', + }), + }, + }, + }) + } else { + executeMsg = makeExecuteSmartContractMessage({ + chainId, + sender, + contractAddress: address, + msg, + funds: funds + .map(({ denom, amount, decimals }) => ({ + denom, + amount: convertDenomToMicroDenomStringWithDecimals( + amount, + decimals + ), + })) + // Neutron errors with `invalid coins` if the funds list is not + // alphabetized. + .sort((a, b) => a.denom.localeCompare(b.denom)), + }) + } + + return account.type === AccountType.Polytone + ? maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + account.chainId, + executeMsg + ) + : account.type === AccountType.Ica + ? maybeMakeIcaExecuteMessages( + this.options.chain.chain_id, + account.chainId, + this.options.address, + account.address, + executeMsg + ) + : executeMsg + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + const isWasmExecute = objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: {}, + }, + }, + }) + + const isSecretExecuteMsg = isDecodedStargateMsg( + decodedMessage, + SecretMsgExecuteContract + ) + + return isWasmExecute || isSecretExecuteMsg + } + + async decode([ + { + decodedMessage, + account: { chainId, address: sender }, + }, + ]: ProcessedMessage[]): Promise { + const isWasmExecute = objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: {}, + }, + }, + }) + + const isSecretExecuteMsg = isDecodedStargateMsg( + decodedMessage, + SecretMsgExecuteContract + ) + + const executeMsg = isWasmExecute + ? decodedMessage.wasm.execute.msg + : isSecretExecuteMsg + ? decodeJsonFromBase64(decodedMessage.stargate.value.msg) + : undefined + + // Check if a CW20 execute, which is a subset of execute. + const isCw20 = + (isWasmExecute && + objectMatchesStructure(executeMsg, { + send: { + amount: {}, + contract: {}, + msg: {}, + }, + })) || + (isSecretExecuteMsg && + objectMatchesStructure(executeMsg, { + send: { + amount: {}, + recipient: {}, + msg: {}, + padding: {}, + }, + })) + + const cw20TokenDecimals = isCw20 + ? ( + await this.options.queryClient.fetchQuery( + tokenQueries.info(this.options.queryClient, { + chainId, + type: TokenType.Cw20, + denomOrAddress: isWasmExecute + ? decodedMessage.wasm.execute.contract_addr + : // Secret Network SNIP20. + bech32DataToAddress( + chainId, + decodedMessage.stargate.value.contract + ), + }) + ) + ).decimals + : 0 + + const funds: Coin[] | undefined = isWasmExecute + ? decodedMessage.wasm.execute.funds + : isSecretExecuteMsg + ? decodedMessage.stargate.value.sentFunds + : undefined + + const fundsTokens = + !isCw20 && funds?.length + ? await Promise.all( + funds.map(async ({ denom, amount }) => ({ + denom, + amount, + decimals: ( + await this.options.queryClient.fetchQuery( + tokenQueries.info(this.options.queryClient, { + chainId, + type: TokenType.Native, + denomOrAddress: denom, + }) + ) + ).decimals, + })) + ) + : [] + + return isWasmExecute + ? { + chainId, + sender, + address: isCw20 + ? executeMsg.send.contract + : decodedMessage.wasm.execute.contract_addr, + message: JSON.stringify( + isCw20 + ? decodeJsonFromBase64(executeMsg.send.msg, true) + : executeMsg, + null, + 2 + ), + funds: isCw20 + ? [ + { + denom: decodedMessage.wasm.execute.contract_addr, + amount: convertMicroDenomToDenomWithDecimals( + executeMsg.send.amount, + cw20TokenDecimals + ), + decimals: cw20TokenDecimals, + }, + ] + : fundsTokens.map(({ denom, amount, decimals }) => ({ + denom, + amount: convertMicroDenomToDenomWithDecimals(amount, decimals), + decimals, + })), + cw20: isCw20, + } + : // isSecretExecuteMsg + { + chainId, + sender, + address: isCw20 + ? executeMsg.send.recipient + : bech32DataToAddress( + chainId, + decodedMessage.stargate.value.contract + ), + message: JSON.stringify( + isCw20 + ? decodeJsonFromBase64(executeMsg.send.msg, true) + : executeMsg, + null, + 2 + ), + funds: isCw20 + ? [ + { + denom: bech32DataToAddress( + chainId, + decodedMessage.stargate.value.contract + ), + amount: convertMicroDenomToDenomWithDecimals( + executeMsg.send.amount, + cw20TokenDecimals + ), + decimals: cw20TokenDecimals, + }, + ] + : fundsTokens.map(({ denom, amount, decimals }) => ({ + denom, + amount: convertMicroDenomToDenomWithDecimals(amount, decimals), + decimals, + })), + cw20: isCw20, + } + } +} diff --git a/packages/stateful/actions/core/actions/ExecuteProposal/Component.stories.tsx b/packages/stateful/actions/core/actions/ExecuteProposal/Component.stories.tsx new file mode 100644 index 000000000..999ad4c29 --- /dev/null +++ b/packages/stateful/actions/core/actions/ExecuteProposal/Component.stories.tsx @@ -0,0 +1,39 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react' + +import { ReactHookFormDecorator } from '@dao-dao/storybook' + +import { + AddressInput, + DaoProviders, + ProposalLine, + ProposalList, +} from '../../../../components' +import { ExecuteProposalComponent } from './Component' + +export default { + title: + 'DAO DAO / packages / stateful / actions / core / dao_governance / ExecuteProposal', + component: ExecuteProposalComponent, + decorators: [ReactHookFormDecorator], +} as ComponentMeta + +const Template: ComponentStory = (args) => ( + +) + +export const Default = Template.bind({}) +Default.args = { + fieldNamePrefix: '', + allActionsWithData: [], + index: 0, + data: {}, + isCreating: true, + errors: {}, + options: { + selectedDaoInfo: { loading: true, errored: false }, + AddressInput, + ProposalLine, + ProposalList, + DaoProviders, + }, +} diff --git a/packages/stateful/actions/core/actions/ExecuteProposal/Component.tsx b/packages/stateful/actions/core/actions/ExecuteProposal/Component.tsx new file mode 100644 index 000000000..aa6ce1f47 --- /dev/null +++ b/packages/stateful/actions/core/actions/ExecuteProposal/Component.tsx @@ -0,0 +1,223 @@ +import { ComponentType, useState } from 'react' +import { useFormContext } from 'react-hook-form' +import toast from 'react-hot-toast' +import { useTranslation } from 'react-i18next' + +import { + Button, + ChainProvider, + DaoSupportedChainPickerInput, + ErrorPage, + InputErrorMessage, + InputLabel, + Loader, + Modal, + useDaoNavHelpers, +} from '@dao-dao/stateless' +import { + AddressInputProps, + DaoInfo, + DaoProvidersProps, + LoadingDataWithError, + StatefulProposalLineProps, + StatefulProposalListProps, +} from '@dao-dao/types' +import { ActionComponent } from '@dao-dao/types/actions' +import { + extractProposalInfo, + getChainForChainId, + isValidBech32Address, + makeValidateAddress, + processError, + validateRequired, +} from '@dao-dao/utils' + +export type ExecuteProposalData = { + chainId: string + coreAddress: string + proposalModuleAddress: string + proposalId: number +} + +export type ExecuteProposalOptions = { + selectedDaoInfo: LoadingDataWithError + AddressInput: ComponentType> + ProposalLine: ComponentType + ProposalList: ComponentType + DaoProviders: ComponentType +} + +export const ExecuteProposalComponent: ActionComponent< + ExecuteProposalOptions +> = ({ + fieldNamePrefix, + errors, + isCreating, + options: { + selectedDaoInfo, + AddressInput, + ProposalLine, + ProposalList, + DaoProviders, + }, +}) => { + const { t } = useTranslation() + const { register, watch, setValue } = useFormContext() + const { getDaoProposalPath } = useDaoNavHelpers() + + const chainId = watch((fieldNamePrefix + 'chainId') as 'chainId') + const coreAddress = watch((fieldNamePrefix + 'coreAddress') as 'coreAddress') + const proposalModuleAddress = watch( + (fieldNamePrefix + 'proposalModuleAddress') as 'proposalModuleAddress' + ) + const proposalId = watch((fieldNamePrefix + 'proposalId') as 'proposalId') + + const selectedProposalModule = + selectedDaoInfo.loading || + selectedDaoInfo.errored || + selectedDaoInfo.updating || + !proposalModuleAddress + ? undefined + : selectedDaoInfo.data.proposalModules.find( + (m) => m.address === proposalModuleAddress + ) + + const [showingProposalList, setShowingProposalList] = useState(false) + + return ( + <> + + +
+ + + + + + + + +
+ +
+ + + {chainId && coreAddress && selectedProposalModule ? ( + + ) : ( + !isCreating && ( +

+ {t('error.unexpectedError')} +

+ ) + )} + + {isCreating && ( + + )} +
+ + setShowingProposalList(false)} + visible={showingProposalList} + > + {selectedDaoInfo.loading ? ( + + ) : selectedDaoInfo.errored ? ( + + ) : ( + + { + if (selectedDaoInfo.loading || selectedDaoInfo.errored) { + return + } + + try { + const { prefix, proposalNumber } = + extractProposalInfo(proposalId) + const proposalModule = + selectedDaoInfo.data.proposalModules.find( + (m) => m.prefix === prefix + ) + if (proposalModule) { + setValue( + (fieldNamePrefix + + 'proposalModuleAddress') as 'proposalModuleAddress', + proposalModule.address + ) + setValue( + (fieldNamePrefix + 'proposalId') as 'proposalId', + proposalNumber + ) + setShowingProposalList(false) + } + } catch (err) { + console.error(err) + toast.error( + processError(err, { + forceCapture: false, + }) + ) + } + }} + onlyExecutable + /> + + )} + + + ) +} diff --git a/packages/stateful/actions/core/dao_governance/VetoOrEarlyExecuteDaoProposal/README.md b/packages/stateful/actions/core/actions/ExecuteProposal/README.md similarity index 64% rename from packages/stateful/actions/core/dao_governance/VetoOrEarlyExecuteDaoProposal/README.md rename to packages/stateful/actions/core/actions/ExecuteProposal/README.md index 10b9a7e03..bb75da61c 100644 --- a/packages/stateful/actions/core/dao_governance/VetoOrEarlyExecuteDaoProposal/README.md +++ b/packages/stateful/actions/core/actions/ExecuteProposal/README.md @@ -1,6 +1,6 @@ -# VetoOrEarlyExecuteDaoProposal +# ExecuteProposal -Veto or early-execute a proposal in a DAO. +Execute a proposal in another DAO. ## Bulk import format @@ -9,7 +9,7 @@ guide](https://github.com/DA0-DA0/dao-dao-ui/wiki/Bulk-importing-actions). ### Key -`vetoOrEarlyExecuteDaoProposal` +`executeProposal` ### Data format @@ -18,7 +18,6 @@ guide](https://github.com/DA0-DA0/dao-dao-ui/wiki/Bulk-importing-actions). "chainId": "", "coreAddress": "", "proposalModuleAddress": "", - "proposalNumber": , - "action": <"veto" | "earlyExecute"> + "proposalNumber": } ``` diff --git a/packages/stateful/actions/core/actions/ExecuteProposal/index.tsx b/packages/stateful/actions/core/actions/ExecuteProposal/index.tsx new file mode 100644 index 000000000..1869167f8 --- /dev/null +++ b/packages/stateful/actions/core/actions/ExecuteProposal/index.tsx @@ -0,0 +1,150 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useFormContext } from 'react-hook-form' + +import { ActionBase, KeyEmoji } from '@dao-dao/stateless' +import { UnifiedCosmosMsg } from '@dao-dao/types' +import { + ActionComponent, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { + getChainAddressForActionOptions, + isValidBech32Address, + makeExecuteSmartContractMessage, + maybeMakePolytoneExecuteMessages, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { getProposalModule } from '../../../../clients' +import { + AddressInput, + DaoProviders, + ProposalLine, + ProposalList, +} from '../../../../components' +import { useQueryLoadingDataWithError } from '../../../../hooks' +import { daoQueries } from '../../../../queries/dao' +import { + ExecuteProposalData, + ExecuteProposalComponent as StatelessExecuteProposalComponent, +} from './Component' + +const Component: ActionComponent = (props) => { + const { watch } = useFormContext() + + const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') + const coreAddress = watch( + (props.fieldNamePrefix + 'coreAddress') as 'coreAddress' + ) + + const queryClient = useQueryClient() + const selectedDaoInfo = useQueryLoadingDataWithError( + daoQueries.info( + queryClient, + chainId && coreAddress && isValidBech32Address(coreAddress) + ? { + chainId, + coreAddress, + } + : undefined + ) + ) + + return ( + + ) +} + +export class ExecuteProposalAction extends ActionBase { + public readonly key = ActionKey.ExecuteProposal + public readonly Component = Component + + constructor(options: ActionOptions) { + super(options, { + Icon: KeyEmoji, + label: options.t('title.executeProposal'), + description: options.t('info.executeProposalDescription'), + }) + + this.defaults = { + chainId: options.chain.chain_id, + coreAddress: '', + proposalModuleAddress: '', + proposalId: -1, + } + } + + encode({ + chainId, + proposalModuleAddress, + proposalId, + }: ExecuteProposalData): UnifiedCosmosMsg[] { + const sender = getChainAddressForActionOptions(this.options, chainId) + if (!sender) { + throw new Error('No sender found for chain') + } + + return maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + chainId, + makeExecuteSmartContractMessage({ + chainId, + sender, + contractAddress: proposalModuleAddress, + msg: { + execute: { + proposal_id: proposalId, + }, + }, + }) + ) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return objectMatchesStructure(decodedMessage.wasm.execute.msg, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + execute: { + proposal_id: {}, + }, + }, + }, + }, + }) + } + + async decode([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): Promise { + const proposalModule = await getProposalModule({ + queryClient: this.options.queryClient, + chainId, + address: decodedMessage.wasm.execute.contract_addr, + }) + + return { + chainId, + coreAddress: proposalModule.dao.coreAddress, + proposalModuleAddress: decodedMessage.wasm.execute.contract_addr, + proposalId: decodedMessage.wasm.execute.msg.veto.proposal_id, + } + } +} diff --git a/packages/stateful/actions/core/smart_contracting/FeeShare/Component.stories.tsx b/packages/stateful/actions/core/actions/FeeShare/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/smart_contracting/FeeShare/Component.stories.tsx rename to packages/stateful/actions/core/actions/FeeShare/Component.stories.tsx diff --git a/packages/stateful/actions/core/smart_contracting/FeeShare/Component.tsx b/packages/stateful/actions/core/actions/FeeShare/Component.tsx similarity index 98% rename from packages/stateful/actions/core/smart_contracting/FeeShare/Component.tsx rename to packages/stateful/actions/core/actions/FeeShare/Component.tsx index ac7f44458..fa68bae75 100644 --- a/packages/stateful/actions/core/smart_contracting/FeeShare/Component.tsx +++ b/packages/stateful/actions/core/actions/FeeShare/Component.tsx @@ -7,6 +7,7 @@ import { InputErrorMessage, InputLabel, SegmentedControlsTitle, + useActionOptions, } from '@dao-dao/stateless' import { AddressInputProps } from '@dao-dao/types' import { ActionComponent } from '@dao-dao/types/actions' @@ -16,8 +17,6 @@ import { } from '@dao-dao/types/protobuf/codegen/juno/feeshare/v1/tx' import { makeValidateAddress } from '@dao-dao/utils' -import { useActionOptions } from '../../../react' - export type FeeShareData = { typeUrl: string contract: string diff --git a/packages/stateful/actions/core/smart_contracting/FeeShare/README.md b/packages/stateful/actions/core/actions/FeeShare/README.md similarity index 100% rename from packages/stateful/actions/core/smart_contracting/FeeShare/README.md rename to packages/stateful/actions/core/actions/FeeShare/README.md diff --git a/packages/stateful/actions/core/actions/FeeShare/index.tsx b/packages/stateful/actions/core/actions/FeeShare/index.tsx new file mode 100644 index 000000000..a667dd344 --- /dev/null +++ b/packages/stateful/actions/core/actions/FeeShare/index.tsx @@ -0,0 +1,91 @@ +import { ActionBase, GasEmoji } from '@dao-dao/stateless' +import { ChainId, UnifiedCosmosMsg, makeStargateMessage } from '@dao-dao/types' +import { + ActionComponent, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { + MsgRegisterFeeShare, + MsgUpdateFeeShare, +} from '@dao-dao/types/protobuf/codegen/juno/feeshare/v1/tx' +import { isDecodedStargateMsg } from '@dao-dao/utils' + +import { AddressInput } from '../../../../components/AddressInput' +import { FeeShareComponent, FeeShareData } from './Component' + +const Component: ActionComponent = (props) => ( + +) + +export class FeeShareAction extends ActionBase { + public readonly key = ActionKey.FeeShare + public readonly Component = Component + + protected _defaults: FeeShareData = { + typeUrl: MsgRegisterFeeShare.typeUrl, + contract: '', + showWithdrawer: false, + withdrawer: '', + } + + constructor(options: ActionOptions) { + // Only supported on Juno. + if ( + options.chain.chain_id !== ChainId.JunoMainnet && + options.chain.chain_id !== ChainId.JunoTestnet + ) { + throw new Error('Fee share is only supported on Juno') + } + + super(options, { + Icon: GasEmoji, + label: options.t('title.feeShare'), + description: options.t('info.feeShareDescription'), + }) + } + + encode({ + contract, + showWithdrawer, + typeUrl, + withdrawer, + }: FeeShareData): UnifiedCosmosMsg { + return makeStargateMessage({ + stargate: { + typeUrl, + value: { + contractAddress: contract, + deployerAddress: this.options.address, + withdrawerAddress: + (showWithdrawer && withdrawer) || this.options.address, + }, + }, + }) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return isDecodedStargateMsg(decodedMessage, [ + MsgRegisterFeeShare, + MsgUpdateFeeShare, + ]) + } + + decode([{ decodedMessage }]: ProcessedMessage[]): FeeShareData { + return { + typeUrl: decodedMessage.stargate.typeUrl, + contract: decodedMessage.stargate.value.contractAddress, + showWithdrawer: + decodedMessage.stargate.value.withdrawerAddress !== + this.options.address, + withdrawer: decodedMessage.stargate.value.withdrawerAddress, + } + } +} diff --git a/packages/stateful/actions/core/actions/FundRebalancer/index.tsx b/packages/stateful/actions/core/actions/FundRebalancer/index.tsx new file mode 100644 index 000000000..0c8f785d1 --- /dev/null +++ b/packages/stateful/actions/core/actions/FundRebalancer/index.tsx @@ -0,0 +1,69 @@ +import { MoneyWingsEmoji } from '@dao-dao/stateless' +import { AccountType, ChainId, ValenceAccount } from '@dao-dao/types' +import { + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { getAccount } from '@dao-dao/utils' + +import { SpendAction } from '../../actions/Spend' + +export class FundRebalancerAction extends SpendAction { + public readonly key = ActionKey.FundRebalancer + + private valenceAccount: ValenceAccount + + constructor(options: ActionOptions) { + super(options) + + // Override Spend metadata. + this._metadata = { + Icon: MoneyWingsEmoji, + label: options.t('title.fundRebalancer'), + description: options.t('info.fundRebalancerDescription'), + } + + const valenceAccount = getAccount({ + accounts: options.context.accounts, + chainId: ChainId.NeutronMainnet, + types: [AccountType.Valence], + }) as ValenceAccount + if (!valenceAccount) { + throw new Error(options.t('error.noValenceAccount')) + } + + this.valenceAccount = valenceAccount + + const SpendComponent = this.Component + this.Component = function FundRebalancerActionComponent(props) { + return + } + } + + async setup() { + await super.setup() + + this.defaults = { + ...this.defaults, + fromChainId: this.options.chain.chain_id, + from: this.options.address, + toChainId: this.valenceAccount.chainId, + to: this.valenceAccount.address, + } + } + + async match(messages: ProcessedMessage[]): Promise { + const match = await super.match(messages) + if (!match) { + return false + } + + const decoded = await this.decode(messages) + return ( + decoded.toChainId === this.valenceAccount.chainId && + decoded.to === this.valenceAccount.address + ) + } +} diff --git a/packages/stateful/actions/core/chain_governance/GovernanceDeposit/Component.stories.tsx b/packages/stateful/actions/core/actions/GovernanceDeposit/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/chain_governance/GovernanceDeposit/Component.stories.tsx rename to packages/stateful/actions/core/actions/GovernanceDeposit/Component.stories.tsx diff --git a/packages/stateful/actions/core/chain_governance/GovernanceDeposit/Component.tsx b/packages/stateful/actions/core/actions/GovernanceDeposit/Component.tsx similarity index 100% rename from packages/stateful/actions/core/chain_governance/GovernanceDeposit/Component.tsx rename to packages/stateful/actions/core/actions/GovernanceDeposit/Component.tsx diff --git a/packages/stateful/actions/core/chain_governance/GovernanceDeposit/README.md b/packages/stateful/actions/core/actions/GovernanceDeposit/README.md similarity index 100% rename from packages/stateful/actions/core/chain_governance/GovernanceDeposit/README.md rename to packages/stateful/actions/core/actions/GovernanceDeposit/README.md diff --git a/packages/stateful/actions/core/actions/GovernanceDeposit/index.tsx b/packages/stateful/actions/core/actions/GovernanceDeposit/index.tsx new file mode 100644 index 000000000..297c82a72 --- /dev/null +++ b/packages/stateful/actions/core/actions/GovernanceDeposit/index.tsx @@ -0,0 +1,290 @@ +import { Coin } from '@cosmjs/stargate' +import { useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' +import { useFormContext } from 'react-hook-form' +import { waitForAll } from 'recoil' + +import { chainQueries, genericTokenSelector } from '@dao-dao/state' +import { + ActionBase, + BankEmoji, + ChainProvider, + DaoSupportedChainPickerInput, + useActionOptions, + useCachedLoading, + useChain, +} from '@dao-dao/stateless' +import { + TokenType, + UnifiedCosmosMsg, + makeStargateMessage, +} from '@dao-dao/types' +import { + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { ProposalStatus } from '@dao-dao/types/protobuf/codegen/cosmos/gov/v1beta1/gov' +import { MsgDeposit } from '@dao-dao/types/protobuf/codegen/cosmos/gov/v1beta1/tx' +import { + getChainAddressForActionOptions, + isDecodedStargateMsg, + maybeMakePolytoneExecuteMessages, +} from '@dao-dao/utils' + +import { GovProposalActionDisplay } from '../../../../components' +import { TokenAmountDisplay } from '../../../../components/TokenAmountDisplay' +import { useQueryLoadingDataWithError } from '../../../../hooks' +import { GovActionsProvider } from '../../../providers/gov' +import { + GovernanceDepositData, + GovernanceDepositComponent as StatelessGovernanceDepositComponent, +} from './Component' + +const Component: ActionComponent = ( + props +) => { + const { context } = useActionOptions() + const { watch, setValue } = useFormContext() + const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') + + return ( + <> + {context.type === ActionContextType.Dao && ( + { + // Clear fields on chain change. + setValue((props.fieldNamePrefix + 'proposalId') as 'proposalId', '') + setValue((props.fieldNamePrefix + 'deposit') as 'deposit', []) + }} + onlyDaoChainIds + /> + )} + + + + + + + + ) +} + +const InnerComponent: ActionComponent = ( + props +) => { + const { isCreating, fieldNamePrefix } = props + const { chain_id: chainId } = useChain() + const { watch, setValue, setError, clearErrors } = + useFormContext() + const queryClient = useQueryClient() + const { context } = useActionOptions() + + // Type-check. This component is wrapped in a gov actions provider. + if (context.type !== ActionContextType.Gov) { + throw new Error('Invalid context for governance deposit action.') + } + + const proposalId = watch( + (props.fieldNamePrefix + 'proposalId') as 'proposalId' + ) + + const proposalOptions = useQueryLoadingDataWithError( + isCreating + ? chainQueries.govProposals(queryClient, { + status: ProposalStatus.PROPOSAL_STATUS_DEPOSIT_PERIOD, + chainId, + }) + : undefined + ) + + // Prevent action from being submitted if there are no deposit proposals. + useEffect(() => { + if ( + proposalOptions.loading || + proposalOptions.errored || + proposalOptions.data.proposals.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 = useQueryLoadingDataWithError( + !isCreating && proposalId + ? chainQueries.govProposal(queryClient, { + proposalId: Number(proposalId), + chainId, + }) + : undefined + ) + + // On proposal change, update deposit to remaining needed. + useEffect(() => { + const proposalSelected = + proposalId && + !proposalOptions.loading && + !proposalOptions.errored && + proposalOptions.data.proposals.find((p) => p.id.toString() === proposalId) + if (!proposalSelected) { + return + } + + const minDeposit = context.params.minDeposit[0] + const missingDeposit = + BigInt(minDeposit.amount) - + BigInt( + proposalSelected.proposal.totalDeposit.find( + ({ denom }) => minDeposit.denom === denom + )?.amount ?? 0 + ) + + if (missingDeposit > 0) { + setValue((fieldNamePrefix + 'deposit') as 'deposit', [ + { + denom: minDeposit.denom, + amount: Number(missingDeposit), + }, + ]) + } + }, [proposalId, proposalOptions, context.params, setValue, fieldNamePrefix]) + + // Select first proposal once loaded if nothing selected. + useEffect(() => { + if ( + isCreating && + !proposalOptions.loading && + !proposalOptions.errored && + proposalOptions.data.proposals.length && + !proposalId + ) { + setValue( + (fieldNamePrefix + 'proposalId') as 'proposalId', + proposalOptions.data.proposals[0].id.toString() + ) + } + }, [isCreating, proposalOptions, proposalId, setValue, fieldNamePrefix]) + + const depositTokens = useCachedLoading( + waitForAll( + context.params.minDeposit.map(({ denom }) => + genericTokenSelector({ + type: TokenType.Native, + denomOrAddress: denom, + chainId, + }) + ) + ), + [] + ) + + return ( + + ) +} + +export class GovernanceDepositAction extends ActionBase { + public readonly key = ActionKey.GovernanceDeposit + public readonly Component = Component + + constructor(options: ActionOptions) { + // Governance module cannot participate in governance. + if (options.context.type === ActionContextType.Gov) { + throw new Error( + 'Governance deposits are not supported by the chain governance context.' + ) + } + + super(options, { + Icon: BankEmoji, + label: options.t('title.depositToGovernanceProposal'), + description: options.t('info.depositToGovernanceProposalDescription'), + }) + + this.defaults = { + chainId: options.chain.chain_id, + proposalId: '', + deposit: [], + } + } + + encode({ + chainId, + proposalId, + deposit, + }: GovernanceDepositData): UnifiedCosmosMsg[] { + const depositor = getChainAddressForActionOptions(this.options, chainId) + if (!depositor) { + throw new Error('Depositor address not found for chain.') + } + + return maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + chainId, + makeStargateMessage({ + stargate: { + typeUrl: MsgDeposit.typeUrl, + value: { + proposalId: BigInt(proposalId || '0'), + depositor, + amount: deposit.map(({ denom, amount }) => ({ + denom, + amount: BigInt(amount).toString(), + })), + } as MsgDeposit, + }, + }) + ) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return isDecodedStargateMsg(decodedMessage, MsgDeposit, { + proposalId: {}, + depositor: {}, + amount: {}, + }) + } + + decode([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): GovernanceDepositData { + return { + chainId, + proposalId: decodedMessage.stargate.value.proposalId.toString(), + deposit: (decodedMessage.stargate.value.amount as Coin[]).map( + ({ denom, amount }) => ({ + denom, + amount: Number(amount), + }) + ), + } + } +} diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.stories.tsx b/packages/stateful/actions/core/actions/GovernanceProposal/Component.stories.tsx similarity index 94% rename from packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.stories.tsx rename to packages/stateful/actions/core/actions/GovernanceProposal/Component.stories.tsx index 5a19132d7..e2f86c195 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.stories.tsx +++ b/packages/stateful/actions/core/actions/GovernanceProposal/Component.stories.tsx @@ -1,7 +1,7 @@ import { ComponentMeta, ComponentStory } from '@storybook/react' import { CHAIN_ID, ReactHookFormDecorator } from '@dao-dao/storybook' -import { GovProposalVersion } from '@dao-dao/types' +import { ActionContextType, GovProposalVersion } from '@dao-dao/types' import { SoftwareUpgradeProposal } from '@dao-dao/types/protobuf/codegen/cosmos/upgrade/v1beta1/upgrade' import { Any } from '@dao-dao/types/protobuf/codegen/google/protobuf/any' @@ -73,14 +73,14 @@ Default.args = { isCreating: true, errors: {}, options: { - supportsV1GovProposals: true, minDeposits: { loading: false, data: [] }, communityPoolBalances: { loading: false, data: [] }, + encodeContext: { + type: ActionContextType.Wallet, + }, TokenAmountDisplay, AddressInput, GovProposalActionDisplay, - loadedActions: {}, - categories: [], SuspenseLoader, }, } diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx b/packages/stateful/actions/core/actions/GovernanceProposal/Component.tsx similarity index 99% rename from packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx rename to packages/stateful/actions/core/actions/GovernanceProposal/Component.tsx index 7bfaab360..1fe523a82 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceProposal/Component.tsx +++ b/packages/stateful/actions/core/actions/GovernanceProposal/Component.tsx @@ -19,6 +19,7 @@ import { TextAreaInput, TextInput, TokenInput, + useActionOptions, useChainContext, useDaoInfoContextIfAvailable, } from '@dao-dao/stateless' @@ -51,10 +52,7 @@ import { validateRequired, } from '@dao-dao/utils' -import { useActionOptions } from '../../../react' - export type GovernanceProposalOptions = { - supportsV1GovProposals: boolean minDeposits: LoadingData< (GenericTokenBalance & { min: string @@ -75,7 +73,6 @@ export const GovernanceProposalComponent: ActionComponent< errors, isCreating, options: { - supportsV1GovProposals, minDeposits, communityPoolBalances, GovProposalActionDisplay, @@ -95,6 +92,8 @@ export const GovernanceProposalComponent: ActionComponent< throw new Error('Invalid action context.') } + const supportsV1GovProposals = context.params.supportsV1 + const useV1LegacyContent = watch( (fieldNamePrefix + 'useV1LegacyContent') as 'useV1LegacyContent' ) diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/README.md b/packages/stateful/actions/core/actions/GovernanceProposal/README.md similarity index 100% rename from packages/stateful/actions/core/chain_governance/GovernanceProposal/README.md rename to packages/stateful/actions/core/actions/GovernanceProposal/README.md diff --git a/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx b/packages/stateful/actions/core/actions/GovernanceProposal/index.tsx similarity index 56% rename from packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx rename to packages/stateful/actions/core/actions/GovernanceProposal/index.tsx index b9b89cc88..4512de17d 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceProposal/index.tsx +++ b/packages/stateful/actions/core/actions/GovernanceProposal/index.tsx @@ -1,21 +1,21 @@ import { Coin } from '@cosmjs/stargate' -import { useEffect } from 'react' +import { MutableRefObject, useEffect, useRef } from 'react' import { useFormContext } from 'react-hook-form' -import { useRecoilValue, waitForAll } from 'recoil' +import { waitForAll } from 'recoil' import { - chainSupportsV1GovModuleSelector, + chainQueries, communityPoolBalancesSelector, genericTokenBalanceSelector, genericTokenSelector, - govParamsSelector, } from '@dao-dao/state' import { + ActionBase, ChainProvider, DaoSupportedChainPickerInput, RaisedHandEmoji, + useActionOptions, useCachedLoading, - useCachedLoadingWithError, } from '@dao-dao/stateless' import { AccountType, @@ -23,15 +23,15 @@ import { ActionComponentProps, ActionContextType, ActionKey, - ActionMaker, + ActionMatch, + ActionOptions, ChainId, GOVERNANCE_PROPOSAL_TYPES, GovProposalVersion, GovernanceProposalActionData, + ProcessedMessage, TokenType, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, + UnifiedCosmosMsg, cwMsgToProtobuf, makeStargateMessage, } from '@dao-dao/types' @@ -47,23 +47,18 @@ import { SoftwareUpgradeProposal } from '@dao-dao/types/protobuf/codegen/cosmos/ import { Any } from '@dao-dao/types/protobuf/codegen/google/protobuf/any' import { decodeGovProposalV1Messages, - decodePolytoneExecuteMsg, getChainAddressForActionOptions, getNativeTokenForChainId, isDecodedStargateMsg, - maybeMakePolytoneExecuteMessage, - objectMatchesStructure, + maybeMakePolytoneExecuteMessages, } from '@dao-dao/utils' import { GovProposalActionDisplay } from '../../../../components' import { AddressInput } from '../../../../components/AddressInput' import { SuspenseLoader } from '../../../../components/SuspenseLoader' import { TokenAmountDisplay } from '../../../../components/TokenAmountDisplay' -import { - GovActionsProvider, - useActionOptions, - useLoadedActionsAndCategories, -} from '../../../react' +import { useActionEncodeContext } from '../../../context' +import { GovActionsProvider } from '../../../providers/gov' import { GovernanceProposalComponent as StatelessGovernanceProposalComponent } from './Component' const Component: ActionComponent = ( @@ -73,6 +68,10 @@ const Component: ActionComponent = ( const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') const options = useActionOptions() + // Store whether or not deposit has initially been set. We don't want to reset + // it if loaded from state; only if chain changes. + const firstDepositSet = useRef(false) + return ( <> {options.context.type === ActionContextType.Dao && ( @@ -94,6 +93,7 @@ const Component: ActionComponent = ( {...props} options={{ address: getChainAddressForActionOptions(options, chainId), + firstDepositSet, }} /> @@ -103,33 +103,29 @@ const Component: ActionComponent = ( } const InnerComponent = ({ - options: { address }, + options: { address, firstDepositSet }, ...props }: ActionComponentProps< - { address: string | undefined }, + { + address: string | undefined + firstDepositSet: MutableRefObject + }, GovernanceProposalActionData >) => { const { watch, setValue } = useFormContext() const expedited = watch((props.fieldNamePrefix + 'expedited') as 'expedited') - // `GovActionsProvier` wraps this, which sets these values. const { - address: govModuleAddress, chain: { chain_id: chainId }, context, } = useActionOptions() + const encodeContext = useActionEncodeContext() // Type-check. if (context.type !== ActionContextType.Gov) { throw new Error('Invalid action context.') } - const supportsV1GovProposals = useRecoilValue( - chainSupportsV1GovModuleSelector({ - chainId, - }) - ) - const communityPoolBalances = useCachedLoading( communityPoolBalancesSelector({ chainId, @@ -137,38 +133,41 @@ const InnerComponent = ({ [] ) - // Update version in data. - useEffect(() => { - setValue( - (props.fieldNamePrefix + 'version') as 'version', - supportsV1GovProposals - ? GovProposalVersion.V1 - : GovProposalVersion.V1_BETA_1 - ) - }, [supportsV1GovProposals, setValue, props.fieldNamePrefix]) - - // Update gov module address in data. - useEffect(() => { - setValue( - (props.fieldNamePrefix + 'govModuleAddress') as 'govModuleAddress', - govModuleAddress - ) - }, [govModuleAddress, setValue, props.fieldNamePrefix]) - const minDepositParams = expedited && context.params.expeditedMinDeposit?.length ? context.params.expeditedMinDeposit : context.params.minDeposit - // On chain or min deposit change, reset deposit. + // On chain or min deposit change, reset deposit, except first load. useEffect(() => { + if (!firstDepositSet.current) { + firstDepositSet.current = true + return + } + setValue((props.fieldNamePrefix + 'deposit') as 'deposit', [ { denom: minDepositParams[0].denom, amount: Number(minDepositParams[0].amount), }, ]) - }, [chainId, setValue, props.fieldNamePrefix, minDepositParams]) + }, [ + chainId, + setValue, + props.fieldNamePrefix, + minDepositParams, + firstDepositSet, + ]) + + // Update version in data. + useEffect(() => { + setValue( + (props.fieldNamePrefix + 'version') as 'version', + context.params.supportsV1 + ? GovProposalVersion.V1 + : GovProposalVersion.V1_BETA_1 + ) + }, [setValue, props.fieldNamePrefix, context.params.supportsV1]) // Get token info for all deposit tokens. const minDepositTokens = useCachedLoading( @@ -201,15 +200,10 @@ const InnerComponent = ({ [] ) - const { categories, loadedActions } = useLoadedActionsAndCategories({ - isCreating: props.isCreating, - }) - return ( = (options) => { - const { - t, - address, - chain: { chain_id: currentChainId }, - context, - } = options - - const defaultChainId = - // Neutron does not use the x/gov module. If this is a DAO on Neutron, see - // if it has polytone accounts on any other chain. If it does, default to - // one of them. Otherwise, hide the action since it cannot be used. - currentChainId === ChainId.NeutronMainnet || - currentChainId === ChainId.NeutronTestnet - ? context.type === ActionContextType.Dao - ? context.dao.accounts.find((a) => a.type === AccountType.Polytone) - ?.chainId - : undefined - : // If not on Neutron, default to current chain. - currentChainId - - if ( - // Governance module cannot participate in governance. - context.type === ActionContextType.Gov || - !defaultChainId - ) { - return null - } +export class GovernanceProposalAction extends ActionBase { + public readonly key = ActionKey.GovernanceProposal + public readonly Component = Component - const useDefaults: UseDefaults = () => { - const loadingData = useCachedLoadingWithError( - waitForAll([ - govParamsSelector({ - chainId: defaultChainId, - }), - chainSupportsV1GovModuleSelector({ - chainId: defaultChainId, - }), - ]) - ) + private defaultChainId: string - if (loadingData.loading) { - return + constructor(options: ActionOptions) { + if ( + // Governance module cannot participate in governance. + options.context.type === ActionContextType.Gov + ) { + throw new Error('Cannot use in chain governance action context.') } - if (loadingData.errored) { - return loadingData.error + + super(options, { + Icon: RaisedHandEmoji, + label: options.t('title.submitGovernanceProposal'), + description: options.t('info.submitGovernanceProposalDescription'), + }) + + const defaultChainId = + // Neutron does not use the x/gov module. If this is a DAO on Neutron, see + // if it has polytone accounts on any other chain. If it does, default to + // one of them. Otherwise, hide the action since it cannot be used. + options.chain.chain_id === ChainId.NeutronMainnet || + options.chain.chain_id === ChainId.NeutronTestnet + ? options.context.type === ActionContextType.Dao + ? options.context.dao.accounts.find( + (a) => a.type === AccountType.Polytone + )?.chainId + : undefined + : // If not on Neutron, default to current chain. + options.chain.chain_id + + if (!defaultChainId) { + throw new Error('Could not find chain to vote on.') } - const [{ minDeposit }, supportsV1GovProposals] = loadingData.data + this.defaultChainId = defaultChainId + } + + async setup() { + const { minDeposit, supportsV1 } = + await this.options.queryClient.fetchQuery( + chainQueries.govParams(this.options.queryClient, { + chainId: this.defaultChainId, + }) + ) + const deposit = minDeposit[0] - return { - chainId: defaultChainId, - version: supportsV1GovProposals + this.defaults = { + chainId: this.defaultChainId, + version: supportsV1 ? GovProposalVersion.V1 : GovProposalVersion.V1_BETA_1, title: '', @@ -343,14 +334,15 @@ export const makeGovernanceProposalAction: ActionMaker< ] : [ { - denom: getNativeTokenForChainId(defaultChainId).denomOrAddress, + denom: getNativeTokenForChainId(this.defaultChainId) + .denomOrAddress, amount: 0, }, ], legacy: { typeUrl: TextProposal.typeUrl, spends: [], - spendRecipient: address, + spendRecipient: this.options.address, parameterChanges: defaultParameterChanges, upgradePlan: defaultPlan, custom: defaultCustom, @@ -362,46 +354,38 @@ export const makeGovernanceProposalAction: ActionMaker< } } - const useTransformToCosmos: UseTransformToCosmos< - GovernanceProposalActionData - > = - () => - ({ - chainId, - govModuleAddress, - version, - title, - description, - metadata, - deposit, - legacyContent, - msgs, - expedited, - useV1LegacyContent, - }) => { - if (!govModuleAddress) { - throw new Error( - `Could not find gov module address for chain ID ${chainId}.` - ) - } - - let msg - if (version === GovProposalVersion.V1_BETA_1) { - msg = makeStargateMessage({ - stargate: { - typeUrl: MsgSubmitProposalV1Beta1.typeUrl, - value: { - content: legacyContent, - initialDeposit: deposit.map(({ amount, denom }) => ({ - amount: BigInt(amount).toString(), - denom, - })), - proposer: getChainAddressForActionOptions(options, chainId), - } as MsgSubmitProposalV1Beta1, - }, + async encode({ + chainId, + title, + description, + metadata, + deposit, + legacyContent, + msgs, + expedited, + useV1LegacyContent, + }: GovernanceProposalActionData): Promise { + const [govModuleAddress, supportsV1] = await Promise.all([ + this.options.queryClient.fetchQuery( + chainQueries.moduleAddress({ + chainId, + name: 'gov', + }) + ), + this.options.queryClient.fetchQuery( + chainQueries.supportsV1GovModule(this.options.queryClient, { + chainId, }) - } else { - msg = makeStargateMessage({ + ), + ]) + + const proposer = getChainAddressForActionOptions(this.options, chainId) + if (!proposer) { + throw new Error('Could not find proposer address.') + } + + const msg = supportsV1 + ? makeStargateMessage({ stargate: { typeUrl: MsgSubmitProposalV1.typeUrl, value: { @@ -419,7 +403,7 @@ export const makeGovernanceProposalAction: ActionMaker< amount: BigInt(amount).toString(), denom, })), - proposer: getChainAddressForActionOptions(options, chainId), + proposer, title, summary: description, // In case it's undefined, default to false. @@ -429,53 +413,69 @@ export const makeGovernanceProposalAction: ActionMaker< } as MsgSubmitProposalV1, }, }) - } - - return maybeMakePolytoneExecuteMessage(currentChainId, chainId, msg) - } - - const useDecodedCosmosMsg: UseDecodedCosmosMsg< - GovernanceProposalActionData - > = (msg: Record) => { - let chainId = currentChainId - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - } + : makeStargateMessage({ + stargate: { + typeUrl: MsgSubmitProposalV1Beta1.typeUrl, + value: { + content: legacyContent, + initialDeposit: deposit.map(({ amount, denom }) => ({ + amount: BigInt(amount).toString(), + denom, + })), + proposer, + } as MsgSubmitProposalV1Beta1, + }, + }) - const defaults = useDefaults() - if (!defaults || defaults instanceof Error) { - return { - match: false, - } - } + return maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + chainId, + msg + ) + } + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { if ( - !isDecodedStargateMsg(msg) || - !objectMatchesStructure(msg.stargate.value, { - proposer: {}, - }) + !isDecodedStargateMsg( + decodedMessage, + [MsgSubmitProposalV1, MsgSubmitProposalV1Beta1], + { + proposer: {}, + } + ) ) { - return { - match: false, - } + return false } - if ( - msg.stargate.typeUrl === MsgSubmitProposalV1Beta1.typeUrl && - msg.stargate.value.content - ) { - const proposal = msg.stargate.value as MsgSubmitProposalV1Beta1 - const type = proposal.content?.typeUrl + // Additional checks for v1beta1 message. + if (decodedMessage.stargate.typeUrl === MsgSubmitProposalV1Beta1.typeUrl) { + const proposal = decodedMessage.stargate.value as MsgSubmitProposalV1Beta1 + const typeUrl = proposal.content?.typeUrl + if ( !proposal.content || - !type || - !GOVERNANCE_PROPOSAL_TYPES.some(({ typeUrl }) => typeUrl === type) + !typeUrl || + !GOVERNANCE_PROPOSAL_TYPES.some((t) => t.typeUrl === typeUrl) ) { - return { - match: false, - } + return false + } + } + + return true + } + + decode([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): Partial { + if (decodedMessage.stargate.typeUrl === MsgSubmitProposalV1Beta1.typeUrl) { + const proposal = decodedMessage.stargate.value as MsgSubmitProposalV1Beta1 + const typeUrl = proposal.content?.typeUrl + // Type-check. Already checked in match. + if (!proposal.content || !typeUrl) { + throw new Error('Invalid proposal content.') } // Try to stringify all proposal content for custom field, but ignore @@ -486,87 +486,67 @@ export const makeGovernanceProposalAction: ActionMaker< } catch {} return { - match: true, - data: { - ...defaults, - chainId, - version: GovProposalVersion.V1_BETA_1, - title: proposal.content.title, - description: proposal.content.description, - metadata: '', - deposit: proposal.initialDeposit.map(({ amount, ...coin }) => ({ - ...coin, - amount: Number(amount), - })), - legacy: { - typeUrl: type, - spends: - proposal.content.typeUrl === CommunityPoolSpendProposal.typeUrl - ? (proposal.content.amount as Coin[]).map( - ({ amount, denom }) => ({ - amount: Number(amount), - denom, - }) - ) - : [], - spendRecipient: - proposal.content.typeUrl === CommunityPoolSpendProposal.typeUrl - ? proposal.content.recipient - : address, - parameterChanges: - proposal.content.typeUrl === ParameterChangeProposal.typeUrl - ? JSON.stringify(proposal.content.changes, null, 2) - : defaultParameterChanges, - upgradePlan: - proposal.content.typeUrl === SoftwareUpgradeProposal.typeUrl - ? JSON.stringify(proposal.content.plan, null, 2) - : defaultPlan, - custom: customContent, - }, - legacyContent: proposal.content, + chainId, + version: GovProposalVersion.V1_BETA_1, + title: proposal.content.title, + description: proposal.content.description, + metadata: '', + deposit: proposal.initialDeposit.map(({ amount, ...coin }) => ({ + ...coin, + amount: Number(amount), + })), + legacy: { + typeUrl, + spends: + proposal.content.typeUrl === CommunityPoolSpendProposal.typeUrl + ? (proposal.content.amount as Coin[]).map( + ({ amount, denom }) => ({ + amount: Number(amount), + denom, + }) + ) + : [], + spendRecipient: + proposal.content.typeUrl === CommunityPoolSpendProposal.typeUrl + ? proposal.content.recipient + : this.options.address, + parameterChanges: + proposal.content.typeUrl === ParameterChangeProposal.typeUrl + ? JSON.stringify(proposal.content.changes, null, 2) + : defaultParameterChanges, + upgradePlan: + proposal.content.typeUrl === SoftwareUpgradeProposal.typeUrl + ? JSON.stringify(proposal.content.plan, null, 2) + : defaultPlan, + custom: customContent, }, + legacyContent: proposal.content, } - } - - if (msg.stargate.typeUrl === MsgSubmitProposalV1.typeUrl) { - const proposal = msg.stargate.value as MsgSubmitProposalV1 + } else if ( + decodedMessage.stargate.typeUrl === MsgSubmitProposalV1.typeUrl + ) { + const proposal = decodedMessage.stargate.value as MsgSubmitProposalV1 const decodedMessages = decodeGovProposalV1Messages( chainId, proposal.messages ) return { - match: true, - data: { - ...defaults, - chainId, - version: GovProposalVersion.V1, - title: proposal.title, - description: proposal.summary, - metadata: proposal.metadata, - deposit: proposal.initialDeposit.map(({ amount, ...coin }) => ({ - ...coin, - amount: Number(amount), - })), - msgs: decodedMessages, - expedited: proposal.expedited || false, - }, + chainId, + version: GovProposalVersion.V1, + title: proposal.title, + description: proposal.summary, + metadata: proposal.metadata, + deposit: proposal.initialDeposit.map(({ amount, ...coin }) => ({ + ...coin, + amount: Number(amount), + })), + msgs: decodedMessages, + expedited: proposal.expedited || false, } } - return { - match: false, - } - } - - return { - key: ActionKey.GovernanceProposal, - Icon: RaisedHandEmoji, - label: t('title.submitGovernanceProposal'), - description: t('info.submitGovernanceProposalDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, + // Should never happen since match checks the type. + throw new Error('Invalid proposal message type.') } } diff --git a/packages/stateful/actions/core/chain_governance/GovernanceVote/Component.stories.tsx b/packages/stateful/actions/core/actions/GovernanceVote/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/chain_governance/GovernanceVote/Component.stories.tsx rename to packages/stateful/actions/core/actions/GovernanceVote/Component.stories.tsx diff --git a/packages/stateful/actions/core/chain_governance/GovernanceVote/Component.tsx b/packages/stateful/actions/core/actions/GovernanceVote/Component.tsx similarity index 97% rename from packages/stateful/actions/core/chain_governance/GovernanceVote/Component.tsx rename to packages/stateful/actions/core/actions/GovernanceVote/Component.tsx index e4a535534..b1f82f566 100644 --- a/packages/stateful/actions/core/chain_governance/GovernanceVote/Component.tsx +++ b/packages/stateful/actions/core/actions/GovernanceVote/Component.tsx @@ -15,11 +15,12 @@ import { ProposalVoteButton, SelectInput, TooltipInfoIcon, + useActionOptions, } from '@dao-dao/stateless' import { GovProposalActionDisplayProps, GovProposalWithDecodedContent, - LoadingData, + LoadingDataWithError, ProposalVoteOption, StatefulTokenAmountDisplayProps, } from '@dao-dao/types' @@ -34,11 +35,9 @@ import { } from '@dao-dao/types/protobuf/codegen/cosmos/gov/v1beta1/gov' import { formatPercentOf100, validateRequired } from '@dao-dao/utils' -import { useActionOptions } from '../../../react' - export interface GovernanceVoteOptions { proposals: GovProposalWithDecodedContent[] - existingVotesLoading?: LoadingData + existingVotesLoading?: LoadingDataWithError TokenAmountDisplay: ComponentType GovProposalActionDisplay: ComponentType } @@ -204,7 +203,8 @@ const VoteFooter = ({ {isCreating && existingVotesLoading && !existingVotesLoading.loading && - !!existingVotesLoading.data?.length && ( + !existingVotesLoading.errored && + !!existingVotesLoading.data.length && (

diff --git a/packages/stateful/actions/core/chain_governance/GovernanceVote/README.md b/packages/stateful/actions/core/actions/GovernanceVote/README.md similarity index 100% rename from packages/stateful/actions/core/chain_governance/GovernanceVote/README.md rename to packages/stateful/actions/core/actions/GovernanceVote/README.md diff --git a/packages/stateful/actions/core/actions/GovernanceVote/index.tsx b/packages/stateful/actions/core/actions/GovernanceVote/index.tsx new file mode 100644 index 000000000..04ae8e84c --- /dev/null +++ b/packages/stateful/actions/core/actions/GovernanceVote/index.tsx @@ -0,0 +1,250 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' +import { useFormContext } from 'react-hook-form' + +import { chainQueries } from '@dao-dao/state' +import { + ActionBase, + BallotDepositEmoji, + ChainProvider, + DaoSupportedChainPickerInput, + Loader, + useActionOptions, +} from '@dao-dao/stateless' +import { + UnifiedCosmosMsg, + cwVoteOptionToGovVoteOption, + govVoteOptionToCwVoteOption, +} from '@dao-dao/types' +import { + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { + ProposalStatus, + VoteOption, +} from '@dao-dao/types/protobuf/codegen/cosmos/gov/v1beta1/gov' +import { MsgVote } from '@dao-dao/types/protobuf/codegen/cosmos/gov/v1beta1/tx' +import { + getChainAddressForActionOptions, + isDecodedStargateMsg, + maybeMakePolytoneExecuteMessages, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { + GovProposalActionDisplay, + SuspenseLoader, +} from '../../../../components' +import { TokenAmountDisplay } from '../../../../components/TokenAmountDisplay' +import { useQueryLoadingDataWithError } from '../../../../hooks' +import { GovActionsProvider } from '../../../providers/gov' +import { + GovernanceVoteData, + GovernanceVoteComponent as StatelessGovernanceVoteComponent, +} from './Component' + +const Component: ActionComponent = (props) => { + const { isCreating, fieldNamePrefix } = props + const options = useActionOptions() + const { watch, setValue, setError, clearErrors } = + useFormContext() + const queryClient = useQueryClient() + + const chainId = watch((fieldNamePrefix + 'chainId') as 'chainId') + const proposalId = watch( + (props.fieldNamePrefix + 'proposalId') as 'proposalId' + ) + + const openProposals = useQueryLoadingDataWithError( + isCreating + ? chainQueries.govProposals(queryClient, { + status: ProposalStatus.PROPOSAL_STATUS_VOTING_PERIOD, + chainId, + }) + : undefined + ) + + // Prevent action from being submitted if there are no open proposals. + useEffect(() => { + if ( + openProposals.loading || + openProposals.errored || + openProposals.data.proposals.length === 0 + ) { + setError((fieldNamePrefix + 'proposalId') as 'proposalId', { + type: 'manual', + }) + } else { + clearErrors((fieldNamePrefix + 'proposalId') as 'proposalId') + } + }, [openProposals, 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 = useQueryLoadingDataWithError( + !isCreating && proposalId + ? chainQueries.govProposal(queryClient, { + proposalId: Number(proposalId), + chainId, + }) + : undefined + ) + + const address = getChainAddressForActionOptions(options, chainId) + const existingVotesLoading = useQueryLoadingDataWithError( + proposalId && address + ? chainQueries.govProposalVote(queryClient, { + proposalId: Number(proposalId), + voter: address, + chainId, + }) + : undefined + ) + + // Select first proposal once loaded if nothing selected. + useEffect(() => { + if ( + isCreating && + !openProposals.loading && + !openProposals.errored && + openProposals.data.proposals.length && + !proposalId + ) { + setValue( + (fieldNamePrefix + 'proposalId') as 'proposalId', + openProposals.data.proposals[0].id.toString() + ) + } + }, [isCreating, openProposals, proposalId, setValue, fieldNamePrefix]) + + return ( + <> + {options.context.type === ActionContextType.Dao && ( + + // Clear proposal on chain change. + setValue((fieldNamePrefix + 'proposalId') as 'proposalId', '') + } + onlyDaoChainIds + /> + )} + + + + } + forceFallback={openProposals.loading || openProposals.errored} + > + + + + + + ) +} + +export class GovernanceVoteAction extends ActionBase { + public readonly key = ActionKey.GovernanceVote + public readonly Component = Component + + constructor(options: ActionOptions) { + // Governance module cannot participate in governance. + if (options.context.type === ActionContextType.Gov) { + throw new Error( + 'Governance deposits are not supported by the chain governance context.' + ) + } + + super(options, { + Icon: BallotDepositEmoji, + label: options.t('title.voteOnGovernanceProposal'), + description: options.t('info.voteOnGovernanceProposalDescription'), + }) + + this.defaults = { + chainId: options.chain.chain_id, + proposalId: '', + vote: VoteOption.VOTE_OPTION_ABSTAIN, + } + } + + encode({ + chainId, + proposalId, + vote, + }: GovernanceVoteData): UnifiedCosmosMsg[] { + return maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + chainId, + { + gov: { + vote: { + proposal_id: Number(proposalId || '-1'), + vote: govVoteOptionToCwVoteOption(vote), + }, + }, + } + ) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return ( + // Stargate protobuf-encoded message. + isDecodedStargateMsg(decodedMessage, MsgVote, { + proposalId: {}, + voter: {}, + option: {}, + }) || + // CosmWasm message. + objectMatchesStructure(decodedMessage, { + gov: { + vote: { + proposal_id: {}, + vote: {}, + }, + }, + }) + ) + } + + decode([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): GovernanceVoteData { + return isDecodedStargateMsg(decodedMessage, MsgVote) + ? { + chainId, + proposalId: decodedMessage.stargate.value.proposalId.toString(), + vote: decodedMessage.stargate.value.option, + } + : { + chainId, + proposalId: decodedMessage.gov.vote.proposal_id.toString(), + vote: cwVoteOptionToGovVoteOption(decodedMessage.gov.vote.vote), + } + } +} diff --git a/packages/stateful/actions/core/advanced/ManageIcas/Component.stories.tsx b/packages/stateful/actions/core/actions/HideIca/Component.stories.tsx similarity index 58% rename from packages/stateful/actions/core/advanced/ManageIcas/Component.stories.tsx rename to packages/stateful/actions/core/actions/HideIca/Component.stories.tsx index 06b533753..0429bb5b4 100644 --- a/packages/stateful/actions/core/advanced/ManageIcas/Component.stories.tsx +++ b/packages/stateful/actions/core/actions/HideIca/Component.stories.tsx @@ -3,17 +3,16 @@ import { ComponentMeta, ComponentStory } from '@storybook/react' import { ReactHookFormDecorator } from '@dao-dao/storybook' import { ChainId } from '@dao-dao/types' -import { ManageIcasComponent } from './Component' +import { HideIcaComponent } from './Component' export default { - title: - 'DAO DAO / packages / stateful / actions / core / advanced / ManageIcas', - component: ManageIcasComponent, + title: 'DAO DAO / packages / stateful / actions / core / advanced / HideIca', + component: HideIcaComponent, decorators: [ReactHookFormDecorator], -} as ComponentMeta +} as ComponentMeta -const Template: ComponentStory = (args) => ( - +const Template: ComponentStory = (args) => ( + ) export const Default = Template.bind({}) diff --git a/packages/stateful/actions/core/advanced/ManageIcas/Component.tsx b/packages/stateful/actions/core/actions/HideIca/Component.tsx similarity index 67% rename from packages/stateful/actions/core/advanced/ManageIcas/Component.tsx rename to packages/stateful/actions/core/actions/HideIca/Component.tsx index 06b6d2595..80d166b19 100644 --- a/packages/stateful/actions/core/advanced/ManageIcas/Component.tsx +++ b/packages/stateful/actions/core/actions/HideIca/Component.tsx @@ -7,62 +7,40 @@ import { InputErrorMessage, InputLabel, RadioInput, - SegmentedControlsTitle, + useActionOptions, } from '@dao-dao/stateless' import { ActionComponent } from '@dao-dao/types/actions' -import { useActionOptions } from '../../../react' - -export type ManageIcasData = { +export type HideIcaData = { chainId: string - register: boolean } -export interface ManageIcasOptions { +export interface HideIcaOptions { currentlyEnabled: string[] } -export const ManageIcasComponent: ActionComponent = ({ +export const HideIcaComponent: ActionComponent = ({ fieldNamePrefix, errors, isCreating, options: { currentlyEnabled }, }) => { const { t } = useTranslation() - const { setValue, watch } = useFormContext() + const { setValue, watch } = useFormContext() const { chain: { chain_id: sourceChainId }, } = useActionOptions() const chainId = watch((fieldNamePrefix + 'chainId') as 'chainId') - const register = watch((fieldNamePrefix + 'register') as 'register') return ( <>

- -

- {register - ? t('info.registerIcaDescription') - : t('info.unregisterIcaDescription')} + {t('info.hideIcaDescription')}

- {!isCreating || register ? ( + {!isCreating ? ( <> diff --git a/packages/stateful/actions/core/actions/HideIca/README.md b/packages/stateful/actions/core/actions/HideIca/README.md new file mode 100644 index 000000000..799745f73 --- /dev/null +++ b/packages/stateful/actions/core/actions/HideIca/README.md @@ -0,0 +1,22 @@ +# HideIca + +Hide an existing Interchain Account (ICA) from the treasury. This is different +from our native cross-chain account implementation that uses Polytone. ICA is +not recommended if a native Polytone cross-chain account is available. + +## 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 + +`hideIca` + +### Data format + +```json +{ + "chainId": " +} +``` diff --git a/packages/stateful/actions/core/actions/HideIca/index.tsx b/packages/stateful/actions/core/actions/HideIca/index.tsx new file mode 100644 index 000000000..32429b726 --- /dev/null +++ b/packages/stateful/actions/core/actions/HideIca/index.tsx @@ -0,0 +1,126 @@ +import { chainQueries } from '@dao-dao/state/query' +import { + ActionBase, + DottedLineFaceEmoji, + useActionOptions, +} from '@dao-dao/stateless' +import { UnifiedCosmosMsg } from '@dao-dao/types' +import { + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { + ICA_CHAINS_TX_PREFIX, + getFilteredDaoItemsByPrefix, +} from '@dao-dao/utils' + +import { ManageStorageItemsAction } from '../ManageStorageItems' +import { + HideIcaData, + HideIcaComponent as StatelessHideIcaComponent, +} from './Component' + +const Component: ActionComponent = (props) => { + const { context } = useActionOptions() + + if (context.type !== ActionContextType.Dao) { + return null + } + + const currentlyEnabled = getFilteredDaoItemsByPrefix( + context.dao.info.items, + ICA_CHAINS_TX_PREFIX + ).map(([key]) => key) + + return ( + + ) +} + +export class HideIcaAction extends ActionBase { + public readonly key = ActionKey.HideIca + public readonly Component = Component + + private manageStorageItemsAction: ManageStorageItemsAction + + constructor(options: ActionOptions) { + if (options.context.type !== ActionContextType.Dao) { + throw new Error('Not DAO context') + } + + const manageStorageItemsAction = new ManageStorageItemsAction(options) + + super(options, { + Icon: DottedLineFaceEmoji, + label: options.t('title.hideIca'), + description: options.t('info.hideIcaDescription'), + // Match just before manage storage items since this action uses that + // under the hood. + matchPriority: manageStorageItemsAction.metadata.matchPriority! + 1, + // Hide until ready. Update this in setup. + hideFromPicker: true, + }) + + this.manageStorageItemsAction = manageStorageItemsAction + + this.defaults = { + chainId: '', + } + + // Fire async init immediately since we may hide this action. + this.init().catch(() => {}) + } + + async setup() { + // Hide from picker if chain does not support ICA controller. + this.metadata.hideFromPicker = !(await this.options.queryClient.fetchQuery( + chainQueries.supportsIcaController({ + chainId: this.options.chain.chain_id, + }) + )) + + return this.manageStorageItemsAction.setup() + } + + encode({ chainId }: HideIcaData): UnifiedCosmosMsg { + return this.manageStorageItemsAction.encode({ + setting: false, + key: ICA_CHAINS_TX_PREFIX + chainId, + // Unused. + value: '', + }) + } + + match(messages: ProcessedMessage[]): ActionMatch { + // Check if manage storage items matches. + const manageStorageItemsMatch = + this.manageStorageItemsAction.match(messages) + if (!manageStorageItemsMatch) { + return manageStorageItemsMatch + } + + // Ensure this is removing an ICA item. + const { setting, key } = this.manageStorageItemsAction.decode(messages) + return ( + !setting && + key.startsWith(ICA_CHAINS_TX_PREFIX) && + key.split(':').length === 2 + ) + } + + decode(messages: ProcessedMessage[]): HideIcaData { + const { key } = this.manageStorageItemsAction.decode(messages) + return { + chainId: key.split(':')[1], + } + } +} diff --git a/packages/stateful/actions/core/advanced/IcaExecute/Component.tsx b/packages/stateful/actions/core/actions/IcaExecute/Component.tsx similarity index 100% rename from packages/stateful/actions/core/advanced/IcaExecute/Component.tsx rename to packages/stateful/actions/core/actions/IcaExecute/Component.tsx diff --git a/packages/stateful/actions/core/advanced/IcaExecute/README.md b/packages/stateful/actions/core/actions/IcaExecute/README.md similarity index 100% rename from packages/stateful/actions/core/advanced/IcaExecute/README.md rename to packages/stateful/actions/core/actions/IcaExecute/README.md diff --git a/packages/stateful/actions/core/advanced/IcaExecute/index.tsx b/packages/stateful/actions/core/actions/IcaExecute/index.tsx similarity index 58% rename from packages/stateful/actions/core/advanced/IcaExecute/index.tsx rename to packages/stateful/actions/core/actions/IcaExecute/index.tsx index 7d34cc6db..ebeb73f63 100644 --- a/packages/stateful/actions/core/advanced/IcaExecute/index.tsx +++ b/packages/stateful/actions/core/actions/IcaExecute/index.tsx @@ -1,67 +1,52 @@ -import { useCallback, useEffect } from 'react' +import { useEffect } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' +import { chainQueries } from '@dao-dao/state/query' +import { icaRemoteAddressSelector } from '@dao-dao/state/recoil' import { - chainSupportsIcaControllerSelector, - icaRemoteAddressSelector, -} from '@dao-dao/state/recoil' -import { + ActionBase, Button, ChainProvider, - CopyToClipboard, - IbcDestinationChainPicker, + DaoSupportedChainPickerInput, InputErrorMessage, Loader, RocketShipEmoji, StatusCard, + useActionOptions, useCachedLoadingWithError, } from '@dao-dao/stateless' import { + AccountType, ActionComponent, ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseHideFromPicker, - UseTransformToCosmos, + ActionMatch, + ActionOptions, + ProcessedMessage, + UnifiedCosmosMsg, } from '@dao-dao/types' import { - decodeIcaExecuteMsg, getDisplayNameForChainId, - maybeMakeIcaExecuteMessage, + maybeMakeIcaExecuteMessages, } from '@dao-dao/utils' import { SuspenseLoader } from '../../../../components' -import { - WalletActionsProvider, - useActionOptions, - useActionsForMatching, - useLoadedActionsAndCategories, -} from '../../../react' +import { useActionEncodeContext } from '../../../context' +import { WalletActionsProvider } from '../../../providers/wallet' import { IcaExecuteData, IcaExecuteComponent as StatelessIcaExecuteComponent, } from './Component' -const InnerComponent: ActionComponent = (props) => { - const { categories, loadedActions } = useLoadedActionsAndCategories({ - isCreating: props.isCreating, - }) - const actionsForMatching = useActionsForMatching() - - return ( - - ) -} +const InnerComponent: ActionComponent = (props) => ( + +) const Component: ActionComponent = (props) => { const { t } = useTranslation() @@ -142,34 +127,14 @@ const Component: ActionComponent = (props) => { return ( <> -
- { - // Type-check. None option is disabled so should not be possible. - if (!chainId) { - return - } - - setValue((props.fieldNamePrefix + 'chainId') as 'chainId', chainId) - }} - selectedChainId={destChainId} - sourceChainId={srcChainId} - /> - - {!icaRemoteAddressLoading.loading && - !icaRemoteAddressLoading.updating && - !icaRemoteAddressLoading.errored && - !!icaRemoteAddressLoading.data && ( - - )} -
+ {!!destChainId && (icaRemoteAddressLoading.loading || icaRemoteAddressLoading.updating ? ( @@ -204,7 +169,7 @@ const Component: ActionComponent = (props) => { }, }) props.addAction({ - actionKey: ActionKey.ManageIcas, + actionKey: ActionKey.HideIca, data: { chainId: destChainId, register: true, @@ -237,73 +202,83 @@ const Component: ActionComponent = (props) => { ) } -export const makeIcaExecuteAction: ActionMaker = ({ - t, - address, - chain: { chain_id: sourceChainId }, -}) => { - const useDefaults: UseDefaults = () => ({ +export class IcaExecuteAction extends ActionBase { + public readonly key = ActionKey.IcaExecute + public readonly Component = Component + + protected _defaults: IcaExecuteData = { chainId: '', icaRemoteAddress: '', msgs: [], - }) + } - const useTransformToCosmos: UseTransformToCosmos = () => - useCallback(({ chainId, icaRemoteAddress, msgs }) => { - if (!chainId || !icaRemoteAddress) { - return - } + constructor(options: ActionOptions) { + super(options, { + Icon: RocketShipEmoji, + label: options.t('title.icaExecute'), + description: options.t('info.icaExecuteDescription'), + // Other actions integrate cross-chain ICA functionality directly, so it + // should be after all the other ones, but it needs to be before Custom, + // since that's the catch-all action. + matchPriority: -99, + // Hide until ready. Update this in setup. + hideFromPicker: true, + }) - return maybeMakeIcaExecuteMessage( - sourceChainId, - chainId, - address, - icaRemoteAddress, - msgs - ) - }, []) + // Fire async init immediately since we may hide this action. + this.init().catch(() => {}) + } + + async setup() { + // Hide from picker if chain does not support ICA controller. + this.metadata.hideFromPicker = !(await this.options.queryClient.fetchQuery( + chainQueries.supportsIcaController({ + chainId: this.options.chain.chain_id, + }) + )) + } - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - const decodedIca = decodeIcaExecuteMsg(sourceChainId, msg, 'any') - if (!decodedIca.match) { - return { - match: false, - } + encode({ + chainId, + icaRemoteAddress, + msgs, + }: IcaExecuteData): UnifiedCosmosMsg[] { + if (!chainId || !icaRemoteAddress) { + throw new Error('Missing chain ID or ICA remote address') } - return { - match: true, - data: { - chainId: decodedIca.chainId, - // Not needed for decoding. - icaRemoteAddress: '', - msgs: decodedIca.cosmosMsgsWithSenders.map(({ msg }) => msg), - }, + if (this.options.chain.chain_id === chainId) { + throw new Error('Cannot execute on the same chain') } - } - // Hide from picker if chain does not support ICA controller. - const useHideFromPicker: UseHideFromPicker = () => { - const supported = useCachedLoadingWithError( - chainSupportsIcaControllerSelector({ - chainId: sourceChainId, - }) + return maybeMakeIcaExecuteMessages( + this.options.chain.chain_id, + chainId, + this.options.address, + icaRemoteAddress, + msgs ) + } - return supported.loading || supported.errored || !supported.data + match([ + { + decodedMessages, + account: { type }, + }, + ]: ProcessedMessage[]): ActionMatch { + return type === AccountType.Ica && decodedMessages.length > 0 } - return { - key: ActionKey.IcaExecute, - Icon: RocketShipEmoji, - label: t('title.icaExecute'), - description: t('info.icaExecuteDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - useHideFromPicker, + decode([ + { + wrappedMessages, + account: { chainId, address }, + }, + ]: ProcessedMessage[]): IcaExecuteData { + return { + chainId, + icaRemoteAddress: address, + msgs: wrappedMessages.map(({ message }) => message), + } } } diff --git a/packages/stateful/actions/core/smart_contracting/Instantiate/Component.stories.tsx b/packages/stateful/actions/core/actions/Instantiate/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/smart_contracting/Instantiate/Component.stories.tsx rename to packages/stateful/actions/core/actions/Instantiate/Component.stories.tsx diff --git a/packages/stateful/actions/core/smart_contracting/Instantiate/Component.tsx b/packages/stateful/actions/core/actions/Instantiate/Component.tsx similarity index 99% rename from packages/stateful/actions/core/smart_contracting/Instantiate/Component.tsx rename to packages/stateful/actions/core/actions/Instantiate/Component.tsx index 6635bb55b..3ecfffdce 100644 --- a/packages/stateful/actions/core/smart_contracting/Instantiate/Component.tsx +++ b/packages/stateful/actions/core/actions/Instantiate/Component.tsx @@ -14,6 +14,7 @@ import { NativeCoinSelector, NumberInput, TextInput, + useActionOptions, useChain, } from '@dao-dao/stateless' import { @@ -31,8 +32,6 @@ import { validateRequired, } from '@dao-dao/utils' -import { useActionOptions } from '../../../react' - export type InstantiateData = { chainId: string sender: string diff --git a/packages/stateful/actions/core/smart_contracting/Instantiate/README.md b/packages/stateful/actions/core/actions/Instantiate/README.md similarity index 100% rename from packages/stateful/actions/core/smart_contracting/Instantiate/README.md rename to packages/stateful/actions/core/actions/Instantiate/README.md diff --git a/packages/stateful/actions/core/smart_contracting/Instantiate/index.tsx b/packages/stateful/actions/core/actions/Instantiate/index.tsx similarity index 56% rename from packages/stateful/actions/core/smart_contracting/Instantiate/index.tsx rename to packages/stateful/actions/core/actions/Instantiate/index.tsx index fa9c2aa2b..34ddcb780 100644 --- a/packages/stateful/actions/core/smart_contracting/Instantiate/index.tsx +++ b/packages/stateful/actions/core/actions/Instantiate/index.tsx @@ -1,49 +1,52 @@ import { fromUtf8 } from '@cosmjs/encoding' import { Coin } from '@cosmjs/stargate' import JSON5 from 'json5' -import { useCallback, useEffect } from 'react' +import { useEffect } from 'react' import { useFormContext } from 'react-hook-form' import { constSelector } from 'recoil' +import { tokenQueries } from '@dao-dao/state/query' import { PolytoneListenerSelectors } from '@dao-dao/state/recoil' import { + ActionBase, BabyEmoji, ChainProvider, DaoSupportedChainPickerInput, + useActionOptions, useCachedLoading, } from '@dao-dao/stateless' -import { AccountType, TokenType, makeStargateMessage } from '@dao-dao/types' +import { + AccountType, + TokenType, + UnifiedCosmosMsg, + makeStargateMessage, +} from '@dao-dao/types' import { ActionComponent, ActionContextType, ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, + ActionMatch, + ActionOptions, + ProcessedMessage, } from '@dao-dao/types/actions' import { MsgInstantiateContract as SecretMsgInstantiateContract } from '@dao-dao/types/protobuf/codegen/secret/compute/v1beta1/msg' import { bech32AddressToBase64, convertDenomToMicroDenomStringWithDecimals, convertMicroDenomToDenomWithDecimals, - decodeIcaExecuteMsg, decodeJsonFromBase64, - decodePolytoneExecuteMsg, encodeJsonToBase64, getAccountAddress, isDecodedStargateMsg, isSecretNetwork, makeWasmMessage, - maybeMakeIcaExecuteMessage, - maybeMakePolytoneExecuteMessage, + maybeMakeIcaExecuteMessages, + maybeMakePolytoneExecuteMessages, objectMatchesStructure, } from '@dao-dao/utils' -import { useQueryTokens } from '../../../../hooks' import { useExecutedProposalTxLoadable } from '../../../../hooks/useExecutedProposalTxLoadable' import { useTokenBalances } from '../../../hooks' -import { useActionOptions } from '../../../react' import { InstantiateData, InstantiateComponent as StatelessInstantiateComponent, @@ -51,7 +54,7 @@ import { // Account types that are allowed to instantiate from. const ALLOWED_ACCOUNT_TYPES: readonly AccountType[] = [ - AccountType.Native, + AccountType.Base, AccountType.Polytone, AccountType.Ica, ] @@ -256,246 +259,216 @@ const Component: ActionComponent = (props) => { ) } -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - let { - chain: { chain_id: chainId }, - address: sender, - context: { accounts }, - } = useActionOptions() - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - sender = - getAccountAddress({ - accounts, - chainId, - types: [AccountType.Polytone], - }) || '' - } else { - const decodedIca = decodeIcaExecuteMsg(chainId, msg) - if (decodedIca.match) { - chainId = decodedIca.chainId - // should never be undefined since we check for 1 message in the decoder - msg = decodedIca.msgWithSender?.msg || {} - sender = decodedIca.msgWithSender?.sender || '' +export class InstantiateAction extends ActionBase { + public readonly key = ActionKey.Instantiate + public readonly Component = Component + + constructor(options: ActionOptions) { + super(options, { + Icon: BabyEmoji, + label: options.t('title.instantiateSmartContract'), + description: options.t('info.instantiateSmartContractActionDescription'), + // Some other actions are instantiate2 actions, so this needs to be after + // them but before cross chain and ICA execute. + matchPriority: -90, + }) + + this.defaults = { + chainId: options.chain.chain_id, + sender: options.address, + admin: options.address, + codeId: 0, + label: '', + message: '{}', + funds: [], + } + } + + encode({ + chainId, + sender, + admin, + codeId, + label, + message, + funds, + }: InstantiateData): UnifiedCosmosMsg | UnifiedCosmosMsg[] { + const account = this.options.context.accounts.find( + (a) => a.chainId === chainId && a.address === sender + ) + if (!account) { + throw new Error('Instantiator account not found') } + + const msg = JSON5.parse(message) + + const convertedFunds = funds + .map(({ denom, amount, decimals }) => ({ + denom, + amount: convertDenomToMicroDenomStringWithDecimals(amount, decimals), + })) + // Neutron errors with `invalid coins` if the funds list is not + // alphabetized. + .sort((a, b) => a.denom.localeCompare(b.denom)) + + const instantiateMsg = isSecretNetwork(chainId) + ? makeStargateMessage({ + stargate: { + typeUrl: SecretMsgInstantiateContract.typeUrl, + value: SecretMsgInstantiateContract.fromAmino({ + sender: bech32AddressToBase64(sender), + admin: admin || '', + code_id: BigInt(codeId).toString(), + init_funds: convertedFunds, + label, + init_msg: encodeJsonToBase64(msg), + }), + }, + }) + : makeWasmMessage({ + wasm: { + instantiate: { + admin: admin || '', + code_id: codeId, + funds: convertedFunds, + label, + msg, + }, + }, + }) + + return account.type === AccountType.Polytone + ? maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + account.chainId, + instantiateMsg + ) + : account.type === AccountType.Ica + ? maybeMakeIcaExecuteMessages( + this.options.chain.chain_id, + account.chainId, + this.options.address, + account.address, + instantiateMsg + ) + : instantiateMsg } - const isWasmInstantiateMsg = objectMatchesStructure(msg, { - wasm: { - instantiate: { - code_id: {}, - label: {}, - msg: {}, - funds: {}, + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + const isWasmInstantiateMsg = objectMatchesStructure(decodedMessage, { + wasm: { + instantiate: { + code_id: {}, + label: {}, + msg: {}, + funds: {}, + }, }, - }, - }) + }) - const isSecretInstantiateMsg = - isDecodedStargateMsg(msg) && - msg.stargate.typeUrl === SecretMsgInstantiateContract.typeUrl - - const funds: Coin[] | undefined = isWasmInstantiateMsg - ? msg.wasm.instantiate.funds - : isSecretInstantiateMsg - ? msg.stargate.value.initFunds - : undefined - - const fundsTokens = useQueryTokens( - funds?.map(({ denom }) => ({ - chainId, - type: TokenType.Native, - denomOrAddress: denom, - })) - ) + const isSecretInstantiateMsg = isDecodedStargateMsg( + decodedMessage, + SecretMsgInstantiateContract + ) - // Can't match until we have the token info. - if (fundsTokens.loading || fundsTokens.errored) { - return { match: false } + return isWasmInstantiateMsg || isSecretInstantiateMsg } - return isWasmInstantiateMsg - ? { - match: true, - data: { + async decode([ + { + decodedMessage, + account: { chainId, address: sender }, + polytone, + }, + ]: ProcessedMessage[]): Promise { + const isWasmInstantiateMsg = objectMatchesStructure(decodedMessage, { + wasm: { + instantiate: { + code_id: {}, + label: {}, + msg: {}, + funds: {}, + }, + }, + }) + + const isSecretInstantiateMsg = isDecodedStargateMsg( + decodedMessage, + SecretMsgInstantiateContract + ) + + const funds: Coin[] | undefined = isWasmInstantiateMsg + ? decodedMessage.wasm.instantiate.funds + : isSecretInstantiateMsg + ? decodedMessage.stargate.value.initFunds + : undefined + + const fundsTokens = funds?.length + ? await Promise.all( + funds.map(async ({ denom, amount }) => ({ + denom, + amount, + decimals: ( + await this.options.queryClient.fetchQuery( + tokenQueries.info(this.options.queryClient, { + chainId, + type: TokenType.Native, + denomOrAddress: denom, + }) + ) + ).decimals, + })) + ) + : [] + + return isWasmInstantiateMsg + ? { chainId, sender, - admin: msg.wasm.instantiate.admin ?? '', - codeId: msg.wasm.instantiate.code_id, - label: msg.wasm.instantiate.label, - message: JSON.stringify(msg.wasm.instantiate.msg, null, 2), - funds: (msg.wasm.instantiate.funds as Coin[]).map( - ({ denom, amount }, index) => ({ - denom, - amount: convertMicroDenomToDenomWithDecimals( - amount, - fundsTokens.data[index].decimals - ), - decimals: fundsTokens.data[index].decimals, - }) - ), - _polytone: decodedPolytone.match + admin: decodedMessage.wasm.instantiate.admin ?? '', + codeId: decodedMessage.wasm.instantiate.code_id, + label: decodedMessage.wasm.instantiate.label, + message: JSON.stringify(decodedMessage.wasm.instantiate.msg, null, 2), + funds: fundsTokens.map(({ denom, amount, decimals }) => ({ + denom, + amount: convertMicroDenomToDenomWithDecimals(amount, decimals), + decimals, + })), + _polytone: polytone ? { - chainId: decodedPolytone.chainId, - note: decodedPolytone.polytoneConnection, - initiatorMsg: decodedPolytone.initiatorMsg, + chainId: polytone.chainId, + note: polytone.polytoneConnection, + initiatorMsg: polytone.initiatorMsg, } : undefined, - }, - } - : isSecretInstantiateMsg - ? { - match: true, - data: { + } + : // isSecretExecuteMsg + { chainId, sender, - admin: msg.stargate.value.admin ?? '', - codeId: Number(msg.stargate.value.codeId), - label: msg.stargate.value.label, + admin: decodedMessage.stargate.value.admin ?? '', + codeId: Number(decodedMessage.stargate.value.codeId), + label: decodedMessage.stargate.value.label, message: JSON.stringify( - decodeJsonFromBase64(fromUtf8(msg.stargate.value.msg), true), + decodeJsonFromBase64( + fromUtf8(decodedMessage.stargate.value.msg), + true + ), null, 2 ), - funds: (msg.stargate.value.initFunds as Coin[]).map( - ({ denom, amount }, index) => ({ - denom, - amount: convertMicroDenomToDenomWithDecimals( - amount, - fundsTokens.data[index].decimals - ), - decimals: fundsTokens.data[index].decimals, - }) - ), - _polytone: decodedPolytone.match + funds: fundsTokens.map(({ denom, amount, decimals }) => ({ + denom, + amount: convertMicroDenomToDenomWithDecimals(amount, decimals), + decimals, + })), + _polytone: polytone ? { - chainId: decodedPolytone.chainId, - note: decodedPolytone.polytoneConnection, - initiatorMsg: decodedPolytone.initiatorMsg, + chainId: polytone.chainId, + note: polytone.polytoneConnection, + initiatorMsg: polytone.initiatorMsg, } : undefined, - }, - } - : { - match: false, - } -} - -export const makeInstantiateAction: ActionMaker = ( - options -) => { - const { - t, - address, - chain: { chain_id: currentChainId }, - context, - } = options - - const useDefaults: UseDefaults = () => ({ - chainId: currentChainId, - sender: address, - admin: address, - codeId: 0, - label: '', - message: '{}', - funds: [], - }) - - const useTransformToCosmos: UseTransformToCosmos = () => - useCallback( - ({ - chainId, - sender, - admin, - codeId, - label, - message, - funds, - }: InstantiateData) => { - const account = context.accounts.find( - (a) => a.chainId === chainId && a.address === sender - ) - if (!account) { - throw new Error('Instantiator account not found') - } - - let msg - try { - msg = JSON5.parse(message) - } catch (err) { - console.error(`internal error. unparsable message: (${message})`, err) - return } - - const convertedFunds = funds - .map(({ denom, amount, decimals }) => ({ - denom, - amount: convertDenomToMicroDenomStringWithDecimals( - amount, - decimals - ), - })) - // Neutron errors with `invalid coins` if the funds list is not - // alphabetized. - .sort((a, b) => a.denom.localeCompare(b.denom)) - - const instantiateMsg = isSecretNetwork(chainId) - ? makeStargateMessage({ - stargate: { - typeUrl: SecretMsgInstantiateContract.typeUrl, - value: SecretMsgInstantiateContract.fromAmino({ - sender: bech32AddressToBase64(sender), - admin: admin || '', - code_id: BigInt(codeId).toString(), - init_funds: convertedFunds, - label, - init_msg: encodeJsonToBase64(msg), - }), - }, - }) - : makeWasmMessage({ - wasm: { - instantiate: { - admin: admin || '', - code_id: codeId, - funds: convertedFunds, - label, - msg, - }, - }, - }) - - return account.type === AccountType.Polytone - ? maybeMakePolytoneExecuteMessage( - currentChainId, - account.chainId, - instantiateMsg - ) - : account.type === AccountType.Ica - ? maybeMakeIcaExecuteMessage( - currentChainId, - account.chainId, - address, - account.address, - instantiateMsg - ) - : instantiateMsg - }, - [] - ) - - return { - key: ActionKey.Instantiate, - Icon: BabyEmoji, - label: t('title.instantiateSmartContract'), - description: t('info.instantiateSmartContractActionDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, } } diff --git a/packages/stateful/actions/core/smart_contracting/Instantiate2/Component.stories.tsx b/packages/stateful/actions/core/actions/Instantiate2/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/smart_contracting/Instantiate2/Component.stories.tsx rename to packages/stateful/actions/core/actions/Instantiate2/Component.stories.tsx diff --git a/packages/stateful/actions/core/smart_contracting/Instantiate2/Component.tsx b/packages/stateful/actions/core/actions/Instantiate2/Component.tsx similarity index 99% rename from packages/stateful/actions/core/smart_contracting/Instantiate2/Component.tsx rename to packages/stateful/actions/core/actions/Instantiate2/Component.tsx index 55391720c..974c9c7c7 100644 --- a/packages/stateful/actions/core/smart_contracting/Instantiate2/Component.tsx +++ b/packages/stateful/actions/core/actions/Instantiate2/Component.tsx @@ -14,6 +14,7 @@ import { NativeCoinSelector, NumberInput, TextInput, + useActionOptions, useChain, } from '@dao-dao/stateless' import { GenericTokenBalance, LoadingData } from '@dao-dao/types' @@ -27,8 +28,6 @@ import { validateRequired, } from '@dao-dao/utils' -import { useActionOptions } from '../../../react' - export type Instantiate2Data = { chainId: string sender: string diff --git a/packages/stateful/actions/core/smart_contracting/Instantiate2/README.md b/packages/stateful/actions/core/actions/Instantiate2/README.md similarity index 100% rename from packages/stateful/actions/core/smart_contracting/Instantiate2/README.md rename to packages/stateful/actions/core/actions/Instantiate2/README.md diff --git a/packages/stateful/actions/core/actions/Instantiate2/index.tsx b/packages/stateful/actions/core/actions/Instantiate2/index.tsx new file mode 100644 index 000000000..106a07f3f --- /dev/null +++ b/packages/stateful/actions/core/actions/Instantiate2/index.tsx @@ -0,0 +1,349 @@ +import { instantiate2Address } from '@cosmjs/cosmwasm-stargate' +import { fromHex, fromUtf8, toBase64, toUtf8 } from '@cosmjs/encoding' +import { Coin } from '@cosmjs/stargate' +import JSON5 from 'json5' +import { useEffect } from 'react' +import { useFormContext } from 'react-hook-form' +import { constSelector, useRecoilValueLoadable } from 'recoil' +import { v4 as uuidv4 } from 'uuid' + +import { tokenQueries } from '@dao-dao/state/query' +import { codeDetailsSelector } from '@dao-dao/state/recoil' +import { + ActionBase, + BabyAngelEmoji, + ChainProvider, + DaoSupportedChainPickerInput, + useActionOptions, +} from '@dao-dao/stateless' +import { + AccountType, + TokenType, + UnifiedCosmosMsg, + makeStargateMessage, +} from '@dao-dao/types' +import { + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { MsgInstantiateContract2 } from '@dao-dao/types/protobuf/codegen/cosmwasm/wasm/v1/tx' +import { + convertDenomToMicroDenomStringWithDecimals, + convertMicroDenomToDenomWithDecimals, + decodeJsonFromBase64, + getAccountAddress, + isDecodedStargateMsg, + isSecretNetwork, + maybeGetChainForChainId, + maybeMakeIcaExecuteMessages, + maybeMakePolytoneExecuteMessages, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { useTokenBalances } from '../../../hooks' +import { + Instantiate2Data, + Instantiate2Component as StatelessInstantiate2Component, +} from './Component' + +// Account types that are allowed to instantiate from. +const ALLOWED_ACCOUNT_TYPES: readonly AccountType[] = [ + AccountType.Base, + AccountType.Polytone, + AccountType.Ica, +] + +const Component: ActionComponent = (props) => { + const { context, address } = useActionOptions() + + const { watch, setValue } = useFormContext() + const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') + const codeId = watch((props.fieldNamePrefix + 'codeId') as 'codeId') + const salt = watch((props.fieldNamePrefix + 'salt') as 'salt') + const funds = watch((props.fieldNamePrefix + 'funds') as 'funds') + + const sender = watch((props.fieldNamePrefix + 'sender') as 'sender') + // If sender is not found in the list of accounts, reset to the first account + // on the target chain, or an empty account. + useEffect(() => { + if ( + sender && + !context.accounts.some( + (a) => a.chainId === chainId && a.address === sender + ) + ) { + setValue( + (props.fieldNamePrefix + 'sender') as 'sender', + getAccountAddress({ + accounts: context.accounts, + chainId, + types: ALLOWED_ACCOUNT_TYPES, + }) || '' + ) + } + }, [chainId, context.accounts, props.fieldNamePrefix, sender, setValue]) + + // Load checksum of the contract code. + const codeDetailsLoadable = useRecoilValueLoadable( + chainId && codeId && !isNaN(codeId) + ? codeDetailsSelector({ + chainId, + codeId, + }) + : constSelector(undefined) + ) + + const nativeBalances = useTokenBalances({ + filter: TokenType.Native, + // Load selected tokens when not creating in case they are no longer + // returned in the list of all tokens for the given DAO/wallet after the + // proposal is made. + additionalTokens: props.isCreating + ? undefined + : funds.map(({ denom }) => ({ + chainId, + type: TokenType.Native, + denomOrAddress: denom, + })), + }) + + const chain = maybeGetChainForChainId(chainId) + + const instantiatedAddress = + codeDetailsLoadable.state === 'hasValue' && + codeDetailsLoadable.contents && + chain + ? instantiate2Address( + fromHex(codeDetailsLoadable.contents.checksum), + address, + toUtf8(salt), + chain.bech32_prefix + ) + : undefined + + return ( + <> + {context.type === ActionContextType.Dao && ( + { + // Reset funds and update admin/sender when switching chain. + setValue((props.fieldNamePrefix + 'funds') as 'funds', []) + + const chainAddress = + getAccountAddress({ + accounts: context.accounts, + chainId, + types: ALLOWED_ACCOUNT_TYPES, + }) || '' + setValue((props.fieldNamePrefix + 'admin') as 'admin', chainAddress) + setValue( + (props.fieldNamePrefix + 'sender') as 'sender', + chainAddress + ) + }} + /> + )} + + + + token.chainId === chainId && owner.address === sender + ), + }, + instantiatedAddress, + }} + /> + + + ) +} + +export class Instantiate2Action extends ActionBase { + public readonly key = ActionKey.Instantiate + public readonly Component = Component + + constructor(options: ActionOptions) { + // Secret Network does not support instantiate2. + if (isSecretNetwork(options.chain.chain_id)) { + throw new Error('Instantiate2 is not supported on Secret Network.') + } + + super(options, { + Icon: BabyAngelEmoji, + label: options.t('title.instantiatePredictableSmartContract'), + description: options.t( + 'info.instantiatePredictableSmartContractActionDescription' + ), + // Some other actions are instantiate2 actions, so this needs to be after + // them but before cross chain and ICA execute. + matchPriority: -90, + }) + + this.defaults = { + chainId: options.chain.chain_id, + sender: options.address, + admin: options.address, + codeId: 0, + label: '', + message: '{}', + salt: uuidv4(), + funds: [], + } + } + + encode({ + chainId, + sender, + admin, + codeId, + label, + message, + salt, + funds, + }: Instantiate2Data): UnifiedCosmosMsg | UnifiedCosmosMsg[] { + const account = this.options.context.accounts.find( + (a) => a.chainId === chainId && a.address === sender + ) + if (!account) { + throw new Error('Instantiator account not found') + } + + const msg = JSON5.parse(message) + + const convertedFunds = funds + .map(({ denom, amount, decimals }) => ({ + denom, + amount: convertDenomToMicroDenomStringWithDecimals(amount, decimals), + })) + // Neutron errors with `invalid coins` if the funds list is not + // alphabetized. + .sort((a, b) => a.denom.localeCompare(b.denom)) + + const instantiate2Msg = makeStargateMessage({ + stargate: { + typeUrl: MsgInstantiateContract2.typeUrl, + value: MsgInstantiateContract2.fromPartial({ + sender, + admin: admin || '', + codeId: codeId ? BigInt(codeId) : 0n, + label, + msg: toUtf8(JSON.stringify(msg)), + funds: convertedFunds, + salt: toUtf8(salt), + fixMsg: false, + }), + }, + }) + + return account.type === AccountType.Polytone + ? maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + account.chainId, + instantiate2Msg + ) + : account.type === AccountType.Ica + ? maybeMakeIcaExecuteMessages( + this.options.chain.chain_id, + account.chainId, + this.options.address, + account.address, + instantiate2Msg + ) + : instantiate2Msg + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return ( + isDecodedStargateMsg(decodedMessage, MsgInstantiateContract2) || + objectMatchesStructure(decodedMessage, { + wasm: { + instantiate2: { + code_id: {}, + label: {}, + msg: {}, + funds: {}, + salt: {}, + fix_msg: {}, + }, + }, + }) + ) + } + + async decode([ + { + decodedMessage, + account: { chainId, address: sender }, + }, + ]: ProcessedMessage[]): Promise { + // Convert to CW msg format to use consistent logic below. + if (isDecodedStargateMsg(decodedMessage, MsgInstantiateContract2)) { + decodedMessage = { + wasm: { + instantiate2: { + admin: decodedMessage.stargate.value.admin, + code_id: Number(decodedMessage.stargate.value.codeId), + label: decodedMessage.stargate.value.label, + msg: decodeJsonFromBase64( + toBase64(decodedMessage.stargate.value.decodedMessage), + true + ), + funds: decodedMessage.stargate.value.funds, + fix_msg: decodedMessage.stargate.value.fixMsg, + salt: fromUtf8(decodedMessage.stargate.value.salt), + }, + }, + } + } + + const fundsTokens = await Promise.all( + (decodedMessage.wasm.instantiate2.funds as Coin[])?.map( + async ({ denom, amount }) => ({ + denom, + amount, + decimals: ( + await this.options.queryClient.fetchQuery( + tokenQueries.info(this.options.queryClient, { + chainId, + type: TokenType.Native, + denomOrAddress: denom, + }) + ) + ).decimals, + }) + ) || [] + ) + + return { + chainId, + sender, + admin: decodedMessage.stargate.value.admin ?? '', + codeId: Number(decodedMessage.stargate.value.codeId), + label: decodedMessage.stargate.value.label, + message: JSON.stringify( + decodeJsonFromBase64(fromUtf8(decodedMessage.stargate.value.msg), true), + null, + 2 + ), + salt: decodedMessage.wasm.instantiate2.salt, + funds: fundsTokens.map(({ denom, amount, decimals }) => ({ + denom, + amount: convertMicroDenomToDenomWithDecimals(amount, decimals), + decimals, + })), + } + } +} diff --git a/packages/stateful/actions/core/treasury/ManageCw20/Component.stories.tsx b/packages/stateful/actions/core/actions/ManageCw20/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/treasury/ManageCw20/Component.stories.tsx rename to packages/stateful/actions/core/actions/ManageCw20/Component.stories.tsx diff --git a/packages/stateful/actions/core/treasury/ManageCw20/Component.tsx b/packages/stateful/actions/core/actions/ManageCw20/Component.tsx similarity index 97% rename from packages/stateful/actions/core/treasury/ManageCw20/Component.tsx rename to packages/stateful/actions/core/actions/ManageCw20/Component.tsx index 259fa0ac2..daba3ffb9 100644 --- a/packages/stateful/actions/core/treasury/ManageCw20/Component.tsx +++ b/packages/stateful/actions/core/actions/ManageCw20/Component.tsx @@ -58,14 +58,6 @@ export const ManageCw20Component: ActionComponent = ({ return ( <> - {isCreating && ( - - )} -
= ({ ]} /> - {!addingNew && existingTokens.length > 0 && ( + + + {!addingNew && isCreating && existingTokens.length > 0 && ( <>
diff --git a/packages/stateful/actions/core/treasury/ManageCw20/README.md b/packages/stateful/actions/core/actions/ManageCw20/README.md similarity index 100% rename from packages/stateful/actions/core/treasury/ManageCw20/README.md rename to packages/stateful/actions/core/actions/ManageCw20/README.md diff --git a/packages/stateful/actions/core/actions/ManageCw20/index.tsx b/packages/stateful/actions/core/actions/ManageCw20/index.tsx new file mode 100644 index 000000000..b92471add --- /dev/null +++ b/packages/stateful/actions/core/actions/ManageCw20/index.tsx @@ -0,0 +1,275 @@ +import { useEffect, useMemo, useState } from 'react' +import { useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { + constSelector, + useRecoilValue, + useRecoilValueLoadable, + waitForAll, +} from 'recoil' + +import { Cw20BaseSelectors } from '@dao-dao/state' +import { DaoDaoCoreSelectors } from '@dao-dao/state/recoil' +import { ActionBase, TokenEmoji, useActionOptions } from '@dao-dao/stateless' +import { UnifiedCosmosMsg } from '@dao-dao/types' +import { + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { TokenInfoResponse } from '@dao-dao/types/contracts/Cw20Base' +import { + CW20_ITEM_KEY_PREFIX, + POLYTONE_CW20_ITEM_KEY_PREFIX, + getChainForChainId, + isValidBech32Address, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { ManageStorageItemsAction } from '../ManageStorageItems' +import { + ManageCw20Data, + ManageCw20Component as StatelessManageCw20Component, +} from './Component' + +const Component: ActionComponent = (props) => { + const { + address, + chain: { chain_id: currentChainId }, + } = useActionOptions() + + const { t } = useTranslation() + const { fieldNamePrefix } = props + + const { watch } = useFormContext() + + const chainId = watch((fieldNamePrefix + 'chainId') as 'chainId') + const { bech32_prefix: bech32Prefix } = getChainForChainId(chainId) + + const adding = watch(fieldNamePrefix + 'adding') + const tokenAddress = watch(fieldNamePrefix + 'address') + + const tokenInfoLoadable = useRecoilValueLoadable( + tokenAddress && isValidBech32Address(tokenAddress, bech32Prefix) + ? Cw20BaseSelectors.tokenInfoSelector({ + contractAddress: tokenAddress, + chainId, + params: [], + }) + : constSelector(undefined) + ) + + const existingTokenAddresses = useRecoilValue( + DaoDaoCoreSelectors.allCw20TokensSelector({ + contractAddress: address, + chainId: currentChainId, + }) + )[chainId]?.tokens + const existingTokenInfos = useRecoilValue( + waitForAll( + existingTokenAddresses?.map((token) => + Cw20BaseSelectors.tokenInfoSelector({ + contractAddress: token, + chainId, + params: [], + }) + ) ?? [] + ) + ) + const existingTokens = useMemo( + () => + (existingTokenAddresses + ?.map((address, idx) => ({ + address, + info: existingTokenInfos[idx], + })) + // If undefined token info response, ignore the token. + .filter(({ info }) => !!info) ?? []) as { + address: string + info: TokenInfoResponse + }[], + [existingTokenAddresses, existingTokenInfos] + ) + + const [additionalAddressError, setAdditionalAddressError] = useState() + useEffect(() => { + const tokenInfoErrored = tokenInfoLoadable.state === 'hasError' + const noTokensWhenRemoving = !adding && existingTokens.length === 0 + + if (!tokenInfoErrored && !noTokensWhenRemoving) { + if (additionalAddressError) { + setAdditionalAddressError(undefined) + } + return + } + + if (!additionalAddressError) { + setAdditionalAddressError( + tokenInfoErrored + ? t('error.notCw20Address') + : noTokensWhenRemoving + ? t('error.noCw20Tokens') + : // Should never happen. + t('error.unexpectedError') + ) + } + }, [ + tokenInfoLoadable.state, + existingTokens.length, + t, + additionalAddressError, + adding, + ]) + + return ( + + ) +} + +export class ManageCw20Action extends ActionBase { + public readonly key = ActionKey.ManageCw20 + public readonly Component = Component + + private manageStorageItemsAction: ManageStorageItemsAction + + constructor(options: ActionOptions) { + if (options.context.type !== ActionContextType.Dao) { + throw new Error('Not DAO context') + } + + const manageStorageItemsAction = new ManageStorageItemsAction(options) + + super(options, { + Icon: TokenEmoji, + label: options.t('title.manageTreasuryTokens'), + description: options.t('info.manageTreasuryTokensDescription'), + // Match just before manage storage items since this action uses that + // under the hood. + matchPriority: manageStorageItemsAction.metadata.matchPriority! + 1, + }) + + this.manageStorageItemsAction = manageStorageItemsAction + + this.defaults = { + chainId: options.chain.chain_id, + adding: true, + address: '', + } + } + + setup() { + return this.manageStorageItemsAction.setup() + } + + encode({ chainId, adding, address }: ManageCw20Data): UnifiedCosmosMsg { + return this.manageStorageItemsAction.encode({ + setting: adding, + // Use cross-chain prefix if necessary. + key: + chainId === this.options.chain.chain_id + ? CW20_ITEM_KEY_PREFIX + address + : POLYTONE_CW20_ITEM_KEY_PREFIX + chainId + ':' + address, + value: '1', + }) + } + + match(messages: ProcessedMessage[]): ActionMatch { + // Check if manage storage items matches. + const manageStorageItemsMatch = + this.manageStorageItemsAction.match(messages) + if (manageStorageItemsMatch) { + // Ensure this is setting or removing a cw20 item. + const { key } = this.manageStorageItemsAction.decode(messages) + return ( + (key.startsWith(CW20_ITEM_KEY_PREFIX) && key.split(':').length === 2) || + (key.startsWith(POLYTONE_CW20_ITEM_KEY_PREFIX) && + key.split(':').length === 3) + ) + } + + // Otherwise check if it's the regular update message. + const { decodedMessage } = messages[0] + return ( + objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + update_cw20_list: { + to_add: {}, + to_remove: {}, + }, + }, + }, + }, + }) && + // Ensure only one token is being added or removed, but not both, and not + // more than one token. Ideally this component lets you add or remove + // multiple tokens at once, but that's not supported yet. + ((decodedMessage.wasm.execute.msg.update_cw20_list.to_add.length === 1 && + decodedMessage.wasm.execute.msg.update_cw20_list.to_remove.length === + 0) || + (decodedMessage.wasm.execute.msg.update_cw20_list.to_add.length === 0 && + decodedMessage.wasm.execute.msg.update_cw20_list.to_remove.length === + 1)) + ) + } + + decode(messages: ProcessedMessage[]): ManageCw20Data { + // If manage storage items, decode cross-chain cw20 key. + const manageStorageItemsMatch = + this.manageStorageItemsAction.match(messages) + if (manageStorageItemsMatch) { + const { setting, key } = this.manageStorageItemsAction.decode(messages) + if (key.startsWith(CW20_ITEM_KEY_PREFIX)) { + // format is `prefix:[address]` + return { + chainId: messages[0].account.chainId, + adding: setting, + address: key.split(':')[1], + } + } else if (key.startsWith(POLYTONE_CW20_ITEM_KEY_PREFIX)) { + // format is `prefix:[chainId]:[address]` + return { + chainId: key.split(':')[1], + adding: setting, + address: key.split(':')[2], + } + } else { + // Should never happen as this is validated in match above. + throw new Error('Unexpected key format') + } + } + + // Otherwise it's a regular update message. We no longer support creating + // actions with this, but for backwards compatibility, we should still + // detect and display them. + const { + decodedMessage, + account: { chainId }, + } = messages[0] + return { + chainId, + adding: + decodedMessage.wasm.execute.msg.update_cw20_list.to_add.length === 1, + address: + decodedMessage.wasm.execute.msg.update_cw20_list.to_add.length === 1 + ? decodedMessage.wasm.execute.msg.update_cw20_list.to_add[0] + : decodedMessage.wasm.execute.msg.update_cw20_list.to_remove[0], + } + } +} diff --git a/packages/stateful/actions/core/nfts/ManageCw721/Component.stories.tsx b/packages/stateful/actions/core/actions/ManageCw721/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/nfts/ManageCw721/Component.stories.tsx rename to packages/stateful/actions/core/actions/ManageCw721/Component.stories.tsx diff --git a/packages/stateful/actions/core/nfts/ManageCw721/Component.tsx b/packages/stateful/actions/core/actions/ManageCw721/Component.tsx similarity index 89% rename from packages/stateful/actions/core/nfts/ManageCw721/Component.tsx rename to packages/stateful/actions/core/actions/ManageCw721/Component.tsx index f55efaaab..e6f245ee5 100644 --- a/packages/stateful/actions/core/nfts/ManageCw721/Component.tsx +++ b/packages/stateful/actions/core/actions/ManageCw721/Component.tsx @@ -24,12 +24,6 @@ export type ManageCw721Data = { chainId: string adding: boolean address: string - // The core contract validates that the submitted contract is a CW721 - // (https://github.com/DA0-DA0/dao-contracts/blob/main/contracts/dao-core/src/contract.rs#L442-L447), - // but unfortunately it is too restrictive. It only succeeds if the contract - // has the cw721-base ContractInfo response. To allow other NFT contracts to - // be added, we can manually use storage items. - workaround: boolean } interface Token { @@ -64,14 +58,6 @@ export const ManageCw721Component: ActionComponent = ({ return ( <> - {isCreating && ( - - )} -
= ({ ]} /> - {!addingNew && existingTokens.length > 0 && ( + + + {!addingNew && isCreating && existingTokens.length > 0 && ( <>
diff --git a/packages/stateful/actions/core/nfts/ManageCw721/README.md b/packages/stateful/actions/core/actions/ManageCw721/README.md similarity index 100% rename from packages/stateful/actions/core/nfts/ManageCw721/README.md rename to packages/stateful/actions/core/actions/ManageCw721/README.md diff --git a/packages/stateful/actions/core/actions/ManageCw721/index.tsx b/packages/stateful/actions/core/actions/ManageCw721/index.tsx new file mode 100644 index 000000000..465e1a1cf --- /dev/null +++ b/packages/stateful/actions/core/actions/ManageCw721/index.tsx @@ -0,0 +1,301 @@ +import { useEffect, useMemo, useState } from 'react' +import { useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { + constSelector, + useRecoilValue, + useRecoilValueLoadable, + waitForNone, +} from 'recoil' + +import { CommonNftSelectors, DaoDaoCoreSelectors } from '@dao-dao/state/recoil' +import { ActionBase, ImageEmoji, useActionOptions } from '@dao-dao/stateless' +import { UnifiedCosmosMsg } from '@dao-dao/types' +import { + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { ContractInfoResponse } from '@dao-dao/types/contracts/Cw721Base' +import { + CW721_ITEM_KEY_PREFIX, + POLYTONE_CW721_ITEM_KEY_PREFIX, + getChainForChainId, + isValidBech32Address, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { ManageStorageItemsAction } from '../ManageStorageItems' +import { + ManageCw721Data, + ManageCw721Component as StatelessManageCw721Component, +} from './Component' + +const Component: ActionComponent = (props) => { + const { + address, + chain: { chain_id: currentChainId }, + } = useActionOptions() + + const { t } = useTranslation() + const { fieldNamePrefix } = props + + const { watch, setValue } = useFormContext() + + const chainId = watch((fieldNamePrefix + 'chainId') as 'chainId') + const { bech32_prefix: bech32Prefix } = getChainForChainId(chainId) + + const adding = watch(fieldNamePrefix + 'adding') + const tokenAddress = watch(fieldNamePrefix + 'address') + const workaround = watch(fieldNamePrefix + 'workaround') + + const tokenInfoLoadable = useRecoilValueLoadable( + tokenAddress && isValidBech32Address(tokenAddress, bech32Prefix) + ? CommonNftSelectors.contractInfoSelector({ + contractAddress: tokenAddress, + chainId, + params: [], + }) + : constSelector(undefined) + ) + + // If token info is improperly formatted, use workaround. + useEffect(() => { + if (tokenInfoLoadable.state !== 'hasValue' || !tokenInfoLoadable.contents) { + return + } + + // We expect keys to contain exactly `name` and `symbol`. If it contains + // anything else, use the workaround. + const keys = Object.keys(tokenInfoLoadable.contents) + if ( + keys.length !== 2 || + !keys.includes('name') || + !keys.includes('symbol') + ) { + if (!workaround) { + setValue(fieldNamePrefix + 'workaround', true) + } + } else { + if (workaround) { + setValue(fieldNamePrefix + 'workaround', false) + } + } + }, [fieldNamePrefix, setValue, tokenInfoLoadable, workaround]) + + const existingTokenAddresses = useRecoilValue( + DaoDaoCoreSelectors.allCw721CollectionsSelector({ + contractAddress: address, + chainId: currentChainId, + }) + )[chainId]?.collectionAddresses + const existingTokenInfos = useRecoilValue( + waitForNone( + existingTokenAddresses?.map((token) => + CommonNftSelectors.contractInfoSelector({ + contractAddress: token, + chainId, + params: [], + }) + ) ?? [] + ) + ) + const existingTokens = useMemo( + () => + (existingTokenAddresses || []).flatMap((address, idx) => + existingTokenInfos[idx].state === 'hasValue' && + existingTokenInfos[idx].contents + ? { + address, + info: existingTokenInfos[idx].contents as ContractInfoResponse, + } + : [] + ), + [existingTokenAddresses, existingTokenInfos] + ) + + const [additionalAddressError, setAdditionalAddressError] = useState() + useEffect(() => { + const tokenInfoErrored = tokenInfoLoadable.state === 'hasError' + const noTokensWhenRemoving = !adding && existingTokens.length === 0 + + if (!tokenInfoErrored && !noTokensWhenRemoving) { + if (additionalAddressError) { + setAdditionalAddressError(undefined) + } + return + } + + if (!additionalAddressError) { + setAdditionalAddressError( + tokenInfoErrored + ? t('error.notCw721Address') + : noTokensWhenRemoving + ? t('error.noNftCollections') + : // Should never happen. + t('error.unexpectedError') + ) + } + }, [ + tokenInfoLoadable.state, + t, + additionalAddressError, + existingTokens, + adding, + ]) + + return ( + + ) +} + +export class ManageCw721Action extends ActionBase { + public readonly key = ActionKey.ManageCw721 + public readonly Component = Component + + private manageStorageItemsAction: ManageStorageItemsAction + + constructor(options: ActionOptions) { + if (options.context.type !== ActionContextType.Dao) { + throw new Error('Not DAO context') + } + + const manageStorageItemsAction = new ManageStorageItemsAction(options) + + super(options, { + Icon: ImageEmoji, + label: options.t('title.manageTreasuryNfts'), + description: options.t('info.manageTreasuryNftsDescription'), + // Match just before manage storage items since this action uses that + // under the hood. + matchPriority: manageStorageItemsAction.metadata.matchPriority! + 1, + }) + + this.manageStorageItemsAction = manageStorageItemsAction + + this.defaults = { + chainId: options.chain.chain_id, + adding: true, + address: '', + } + } + + setup() { + return this.manageStorageItemsAction.setup() + } + + encode({ chainId, adding, address }: ManageCw721Data): UnifiedCosmosMsg { + return this.manageStorageItemsAction.encode({ + setting: adding, + // Use cross-chain prefix if necessary. + key: + chainId === this.options.chain.chain_id + ? CW721_ITEM_KEY_PREFIX + address + : POLYTONE_CW721_ITEM_KEY_PREFIX + chainId + ':' + address, + value: '1', + }) + } + + match(messages: ProcessedMessage[]): ActionMatch { + // Check if manage storage items matches. + const manageStorageItemsMatch = + this.manageStorageItemsAction.match(messages) + if (manageStorageItemsMatch) { + // Ensure this is setting or removing a cw721 item. + const { key } = this.manageStorageItemsAction.decode(messages) + return ( + (key.startsWith(CW721_ITEM_KEY_PREFIX) && + key.split(':').length === 2) || + (key.startsWith(POLYTONE_CW721_ITEM_KEY_PREFIX) && + key.split(':').length === 3) + ) + } + + // Otherwise check if it's the regular update message. We no longer support + // creating actions with this, but for backwards compatibility, we should + // still detect and display them. + const { decodedMessage } = messages[0] + return ( + objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + update_cw721_list: { + to_add: {}, + to_remove: {}, + }, + }, + }, + }, + }) && + // Ensure only one collection is being added or removed, but not both, and + // not more than one collection. + ((decodedMessage.wasm.execute.msg.update_cw721_list.to_add.length === 1 && + decodedMessage.wasm.execute.msg.update_cw721_list.to_remove.length === + 0) || + (decodedMessage.wasm.execute.msg.update_cw721_list.to_add.length === + 0 && + decodedMessage.wasm.execute.msg.update_cw721_list.to_remove.length === + 1)) + ) + } + + decode(messages: ProcessedMessage[]): ManageCw721Data { + // If manage storage items, decode cw721 key, cross-chain or not. + const manageStorageItemsMatch = + this.manageStorageItemsAction.match(messages) + if (manageStorageItemsMatch) { + const { setting, key } = this.manageStorageItemsAction.decode(messages) + if (key.startsWith(CW721_ITEM_KEY_PREFIX)) { + // format is `prefix:[address]` + return { + chainId: messages[0].account.chainId, + adding: setting, + address: key.split(':')[1], + } + } else if (key.startsWith(POLYTONE_CW721_ITEM_KEY_PREFIX)) { + // format is `prefix:[chainId]:[address]` + return { + chainId: key.split(':')[1], + adding: setting, + address: key.split(':')[2], + } + } else { + // Should never happen as this is validated in match above. + throw new Error('Unexpected key format') + } + } + + // Otherwise it's a regular update message. We no longer support creating + // actions with this, but for backwards compatibility, we should still + // detect and display them. + const { + decodedMessage, + account: { chainId }, + } = messages[0] + return { + chainId, + adding: + decodedMessage.wasm.execute.msg.update_cw721_list.to_add.length === 1, + address: + decodedMessage.wasm.execute.msg.update_cw721_list.to_add.length === 1 + ? decodedMessage.wasm.execute.msg.update_cw721_list.to_add[0] + : decodedMessage.wasm.execute.msg.update_cw721_list.to_remove[0], + } + } +} diff --git a/packages/stateful/actions/core/treasury/ManageStaking/Component.stories.tsx b/packages/stateful/actions/core/actions/ManageStaking/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/treasury/ManageStaking/Component.stories.tsx rename to packages/stateful/actions/core/actions/ManageStaking/Component.stories.tsx diff --git a/packages/stateful/actions/core/treasury/ManageStaking/Component.tsx b/packages/stateful/actions/core/actions/ManageStaking/Component.tsx similarity index 72% rename from packages/stateful/actions/core/treasury/ManageStaking/Component.tsx rename to packages/stateful/actions/core/actions/ManageStaking/Component.tsx index b0c0da751..8ecba5944 100644 --- a/packages/stateful/actions/core/treasury/ManageStaking/Component.tsx +++ b/packages/stateful/actions/core/actions/ManageStaking/Component.tsx @@ -1,4 +1,5 @@ import clsx from 'clsx' +import { TFunction } from 'next-i18next' import { ComponentType, useCallback, useEffect } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' @@ -25,35 +26,33 @@ import { validateRequired, } from '@dao-dao/utils' -export const useStakeActions = (): { +export const getStakeActions = ( + t: TFunction +): { type: StakingActionType name: string -}[] => { - const { t } = useTranslation() - - return [ - { - type: StakingActionType.Delegate, - name: t('title.stake'), - }, - { - type: StakingActionType.Undelegate, - name: t('title.unstake'), - }, - { - type: StakingActionType.Redelegate, - name: t('title.restake'), - }, - { - type: StakingActionType.WithdrawDelegatorReward, - name: t('title.claimRewards'), - }, - { - type: StakingActionType.SetWithdrawAddress, - name: t('title.setWithdrawAddress'), - }, - ] -} +}[] => [ + { + type: StakingActionType.Delegate, + name: t('title.stake'), + }, + { + type: StakingActionType.Undelegate, + name: t('title.unstake'), + }, + { + type: StakingActionType.Redelegate, + name: t('title.restake'), + }, + { + type: StakingActionType.WithdrawDelegatorReward, + name: t('title.claimRewards'), + }, + { + type: StakingActionType.SetWithdrawAddress, + name: t('title.setWithdrawAddress'), + }, +] export interface ManageStakingOptions { nativeBalance: string @@ -99,7 +98,7 @@ export const ManageStakingComponent: ActionComponent< const { register, watch, setError, clearErrors, setValue } = useFormContext() - const stakeActions = useStakeActions() + const stakeActions = getStakeActions(t) const stakedValidatorAddresses = new Set( stakes.map((s) => s.validator.address) @@ -111,6 +110,8 @@ export const ManageStakingComponent: ActionComponent< const toValidator = watch((fieldNamePrefix + 'toValidator') as 'toValidator') const amount = watch((fieldNamePrefix + 'amount') as 'amount') + const selectedAction = stakeActions.find((a) => a.type === type) + const { chain: { bech32_prefix: bech32Prefix }, nativeToken, @@ -167,8 +168,14 @@ export const ManageStakingComponent: ActionComponent< return validateValidator } - // No further validation for claiming rewards. - if (type === StakingActionType.WithdrawDelegatorReward) { + if ( + // Don't validate max stakable amount, instead showing the warning defined + // below all this. This lets users spend funds that are made available by + // previous actions in the same transaction. + type === StakingActionType.Delegate || + // No further validation for claiming rewards. + type === StakingActionType.WithdrawDelegatorReward + ) { return true } @@ -176,22 +183,8 @@ export const ManageStakingComponent: ActionComponent< maximumFractionDigits: 6, }) - // Logic for delegating. - if (type === StakingActionType.Delegate) { - return ( - Number(amount) <= maxAmount || - (maxAmount === 0 - ? t('error.treasuryNoTokensCannotStake', { - tokenSymbol: nativeToken.symbol, - }) - : t('error.treasuryInsufficient', { - amount: humanReadableAmount, - tokenSymbol: nativeToken.symbol, - })) - ) - } // Logic for undelegating. - else if (type === StakingActionType.Undelegate) { + if (type === StakingActionType.Undelegate) { return ( Number(amount) <= sourceValidatorStaked || (sourceValidatorStaked === 0 @@ -256,42 +249,68 @@ export const ManageStakingComponent: ActionComponent< } }, [setError, clearErrors, validate, fieldNamePrefix, amount]) + // A warning if the denom was not found in the treasury or the amount is too + // high. We don't want to make this an error because often people want to + // spend funds that a previous action makes available, so just show a warning. + const delegateWarning = + isCreating && amount > maxAmount && type === StakingActionType.Delegate + ? t('error.insufficientFundsWarning', { + amount: maxAmount.toLocaleString(undefined, { + maximumFractionDigits: nativeToken.decimals, + }), + tokenSymbol: nativeToken.symbol, + }) + : undefined + return ( <>
-
+
{/* Choose type of stake operation. */} - { - // If setting to non-delegate stake type and currently set - // validator is not one we are staked to, set back to first staked - // validator in list. - if ( - value !== StakingActionType.Delegate && - !stakedValidatorAddresses.has(validator) - ) { - setValue( - (fieldNamePrefix + 'validator') as 'validator', - stakes.length > 0 ? stakes[0].validator.address : '' - ) - } - }} - register={register} - > - {stakeActions.map(({ name, type }, idx) => ( - - ))} - + {isCreating ? ( + { + // If setting to non-delegate stake type and currently set + // validator is not one we are staked to, set back to first staked + // validator in list. + if ( + value !== StakingActionType.Delegate && + !stakedValidatorAddresses.has(validator) + ) { + setValue( + (fieldNamePrefix + 'validator') as 'validator', + stakes.length > 0 ? stakes[0].validator.address : '' + ) + } + }} + register={register} + > + {stakeActions.map(({ name, type }, idx) => ( + + ))} + + ) : ( +

+ {selectedAction?.name || t('info.unknown')} +

+ )} {type !== StakingActionType.SetWithdrawAddress && ( // Choose source validator. @@ -325,7 +344,6 @@ export const ManageStakingComponent: ActionComponent< disabled={!isCreating} error={errors?.amount} fieldName={(fieldNamePrefix + 'amount') as 'amount'} - max={maxAmount} min={minAmount} register={register} setValue={setValue} @@ -359,8 +377,8 @@ export const ManageStakingComponent: ActionComponent< // claimed rewards if executed. (executed && !!claimedRewards) || (!executed && sourceValidatorPendingRewards > 0)) && - // Only show balance when delegating if creating. - (type !== StakingActionType.Delegate || isCreating) && ( + // Only show balance if creating. + isCreating && (

{type === StakingActionType.Delegate @@ -420,13 +438,18 @@ export const ManageStakingComponent: ActionComponent<

)} - {(errors?.denom || errors?.amount || errors?._error) && ( -
- - - -
- )} + {isCreating && + (errors?.denom || + errors?.amount || + errors?._error || + delegateWarning) && ( +
+ + + + +
+ )} ) } diff --git a/packages/stateful/actions/core/treasury/ManageStaking/README.md b/packages/stateful/actions/core/actions/ManageStaking/README.md similarity index 100% rename from packages/stateful/actions/core/treasury/ManageStaking/README.md rename to packages/stateful/actions/core/actions/ManageStaking/README.md diff --git a/packages/stateful/actions/core/treasury/ManageStaking/index.tsx b/packages/stateful/actions/core/actions/ManageStaking/index.tsx similarity index 54% rename from packages/stateful/actions/core/treasury/ManageStaking/index.tsx rename to packages/stateful/actions/core/actions/ManageStaking/index.tsx index af1300955..bf7489f18 100644 --- a/packages/stateful/actions/core/treasury/ManageStaking/index.tsx +++ b/packages/stateful/actions/core/actions/ManageStaking/index.tsx @@ -1,6 +1,5 @@ import { coin, parseCoins } from '@cosmjs/amino' import { useQueryClient } from '@tanstack/react-query' -import { useCallback } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' @@ -10,15 +9,16 @@ import { validatorsSelector, } from '@dao-dao/state' import { + ActionBase, ChainProvider, DaoSupportedChainPickerInput, DepositEmoji, Loader, + useActionOptions, useCachedLoading, useChainContext, } from '@dao-dao/stateless' import { - ChainId, Coin, LoadingData, NativeDelegationInfo, @@ -31,10 +31,9 @@ import { ActionComponent, ActionContextType, ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, + ActionMatch, + ActionOptions, + ProcessedMessage, } from '@dao-dao/types/actions' import { MsgSetWithdrawAddress, @@ -49,11 +48,11 @@ import { StakingActionType, convertDenomToMicroDenomWithDecimals, convertMicroDenomToDenomWithDecimals, - decodePolytoneExecuteMsg, getChainAddressForActionOptions, getNativeTokenForChainId, isDecodedStargateMsg, - maybeMakePolytoneExecuteMessage, + maybeMakePolytoneExecuteMessages, + objectMatchesStructure, } from '@dao-dao/utils' import { AddressInput } from '../../../../components/AddressInput' @@ -63,208 +62,12 @@ import { useQueryLoadingData, } from '../../../../hooks' import { useTokenBalances } from '../../../hooks' -import { useActionOptions } from '../../../react' import { ManageStakingData, ManageStakingComponent as StatelessManageStakingComponent, - useStakeActions, + getStakeActions, } from './Component' -const useTransformToCosmos: UseTransformToCosmos = () => { - const options = useActionOptions() - const { - chain: { chain_id: currentChainId }, - } = options - - return useCallback( - ({ - chainId, - type: stakeType, - amount: macroAmount, - validator, - toValidator, - withdrawAddress, - }: ManageStakingData) => { - const nativeToken = getNativeTokenForChainId(chainId) - const microAmount = convertDenomToMicroDenomWithDecimals( - macroAmount, - nativeToken.decimals - ) - - const delegatorAddress = getChainAddressForActionOptions(options, chainId) - const amount = coin( - BigInt(microAmount).toString(), - nativeToken.denomOrAddress - ) - - let msg: UnifiedCosmosMsg - switch (stakeType) { - case StakingActionType.Delegate: - msg = makeStargateMessage({ - stargate: { - typeUrl: MsgDelegate.typeUrl, - value: { - delegatorAddress, - validatorAddress: validator, - amount, - } as MsgDelegate, - }, - }) - break - case StakingActionType.Undelegate: - msg = makeStargateMessage({ - stargate: { - typeUrl: MsgUndelegate.typeUrl, - value: { - delegatorAddress, - validatorAddress: validator, - amount, - } as MsgUndelegate, - }, - }) - break - case StakingActionType.Redelegate: - msg = makeStargateMessage({ - stargate: { - typeUrl: MsgBeginRedelegate.typeUrl, - value: { - delegatorAddress, - validatorSrcAddress: validator, - validatorDstAddress: toValidator, - amount, - } as MsgBeginRedelegate, - }, - }) - break - case StakingActionType.WithdrawDelegatorReward: - msg = makeStargateMessage({ - stargate: { - typeUrl: MsgWithdrawDelegatorReward.typeUrl, - value: { - delegatorAddress, - validatorAddress: validator, - } as MsgWithdrawDelegatorReward, - }, - }) - break - case StakingActionType.SetWithdrawAddress: - msg = makeStargateMessage({ - stargate: { - typeUrl: MsgSetWithdrawAddress.typeUrl, - value: { - delegatorAddress, - withdrawAddress, - } as MsgSetWithdrawAddress, - }, - }) - break - } - - return maybeMakePolytoneExecuteMessage(currentChainId, chainId, msg) - }, - [currentChainId, options] - ) -} - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - let chainId = useActionOptions().chain.chain_id - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - } - - // Convert to CW msg format to use same matching logic below. - if (isDecodedStargateMsg(msg)) { - const cwMsg = decodedStakingStargateMsgToCw(msg.stargate) - if (cwMsg) { - msg = cwMsg - } - } - - const stakeActions = useStakeActions() - const nativeToken = getNativeTokenForChainId(chainId) - - if ('distribution' in msg) { - if ( - StakingActionType.WithdrawDelegatorReward in msg.distribution && - 'validator' in msg.distribution.withdraw_delegator_reward - ) { - return { - match: true, - data: { - chainId, - type: StakingActionType.WithdrawDelegatorReward, - validator: msg.distribution.withdraw_delegator_reward.validator, - // Default values, not needed for displaying this type of message. - toValidator: '', - amount: 1, - withdrawAddress: '', - }, - } - } else if ( - StakingActionType.SetWithdrawAddress in msg.distribution && - 'address' in msg.distribution.set_withdraw_address - ) { - return { - match: true, - data: { - chainId, - type: StakingActionType.SetWithdrawAddress, - withdrawAddress: msg.distribution.set_withdraw_address.address, - validator: '', - toValidator: '', - amount: 1, - }, - } - } - } else if ('staking' in msg) { - const stakeType = stakeActions - .map(({ type }) => type) - .find((type) => type in msg.staking) - if (!stakeType) return { match: false } - - const data = msg.staking[stakeType] - if ( - ((stakeType === StakingActionType.Redelegate && - 'src_validator' in data && - 'dst_validator' in data) || - (stakeType !== StakingActionType.Redelegate && 'validator' in data)) && - 'amount' in data && - 'amount' in data.amount && - 'denom' in data.amount && - data.amount.denom === nativeToken.denomOrAddress - ) { - const { amount } = data.amount - - return { - match: true, - data: { - chainId, - type: stakeType, - validator: - stakeType === StakingActionType.Redelegate - ? data.src_validator - : data.validator, - toValidator: - stakeType === StakingActionType.Redelegate - ? data.dst_validator - : '', - amount: convertMicroDenomToDenomWithDecimals( - amount, - nativeToken.decimals - ), - withdrawAddress: '', - }, - } - } - } - - return { match: false } -} - const InnerComponent: ActionComponent = (props) => { const { t } = useTranslation() const options = useActionOptions() @@ -479,69 +282,288 @@ const Component: ActionComponent = (props) => { ) } -export const makeManageStakingAction: ActionMaker = ({ - t, - chain: { chain_id: chainId }, - address, - context, -}) => { - if ( +export class ManageStakingAction extends ActionBase { + public readonly key = ActionKey.ManageStaking + public readonly Component = Component + + constructor(options: ActionOptions) { // x/gov cannot stake. - context.type === ActionContextType.Gov || - // Neutron does not support staking. - chainId === ChainId.NeutronMainnet || - chainId === ChainId.NeutronTestnet - ) { - return null + if (options.context.type === ActionContextType.Gov) { + throw new Error('Chain governance cannot stake assets') + } + + super(options, { + Icon: DepositEmoji, + label: options.t('title.manageStaking'), + description: options.t('info.manageStakingDescription'), + keywords: [ + 'stake', + 'unstake', + 'restake', + 'delegate', + 'undelegate', + 'redelegate', + ], + }) } - const useDefaults: UseDefaults = () => { - const stakeActions = useStakeActions() - - const queryClient = useQueryClient() - const loadingNativeDelegationInfo = useQueryLoadingData( - address - ? chainQueries.nativeDelegationInfo(queryClient, { - chainId, - address, - }) - : undefined, - { - delegations: [], - unbondingDelegations: [], - } as NativeDelegationInfo - ) + async setup() { + const firstValidator = + (this.options.address && + ( + await this.options.queryClient.fetchQuery( + chainQueries.nativeDelegationInfo(this.options.queryClient, { + chainId: this.options.chain.chain_id, + address: this.options.address, + }) + ) + ).delegations[0]?.validator.address) || + '' - return { - chainId, - type: stakeActions[0].type, + this.defaults = { + chainId: this.options.chain.chain_id, + type: StakingActionType.Delegate, // Default to first validator if exists. - validator: - (!loadingNativeDelegationInfo.loading && - loadingNativeDelegationInfo.data.delegations[0]?.validator.address) || - '', + validator: firstValidator, toValidator: '', amount: 1, - withdrawAddress: address, + withdrawAddress: this.options.address, + } + } + + encode({ + chainId, + type, + amount: macroAmount, + validator, + toValidator, + withdrawAddress, + }: ManageStakingData): UnifiedCosmosMsg[] { + const delegatorAddress = getChainAddressForActionOptions( + this.options, + chainId + ) + const nativeToken = getNativeTokenForChainId(chainId) + const microAmount = convertDenomToMicroDenomWithDecimals( + macroAmount, + nativeToken.decimals + ) + const amount = coin( + BigInt(microAmount).toString(), + nativeToken.denomOrAddress + ) + + let msg: UnifiedCosmosMsg + switch (type) { + case StakingActionType.Delegate: + msg = makeStargateMessage({ + stargate: { + typeUrl: MsgDelegate.typeUrl, + value: MsgDelegate.fromPartial({ + delegatorAddress, + validatorAddress: validator, + amount, + }), + }, + }) + break + case StakingActionType.Undelegate: + msg = makeStargateMessage({ + stargate: { + typeUrl: MsgUndelegate.typeUrl, + value: MsgUndelegate.fromPartial({ + delegatorAddress, + validatorAddress: validator, + amount, + }), + }, + }) + break + case StakingActionType.Redelegate: + msg = makeStargateMessage({ + stargate: { + typeUrl: MsgBeginRedelegate.typeUrl, + value: MsgBeginRedelegate.fromPartial({ + delegatorAddress, + validatorSrcAddress: validator, + validatorDstAddress: toValidator, + amount, + }), + }, + }) + break + case StakingActionType.WithdrawDelegatorReward: + msg = makeStargateMessage({ + stargate: { + typeUrl: MsgWithdrawDelegatorReward.typeUrl, + value: { + delegatorAddress, + validatorAddress: validator, + } as MsgWithdrawDelegatorReward, + }, + }) + break + case StakingActionType.SetWithdrawAddress: + msg = makeStargateMessage({ + stargate: { + typeUrl: MsgSetWithdrawAddress.typeUrl, + value: MsgSetWithdrawAddress.fromPartial({ + delegatorAddress, + withdrawAddress, + }), + }, + }) + break + } + + return maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + chainId, + msg + ) + } + + match([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): ActionMatch { + // Convert to CW msg format to use consistent logic below. + if (isDecodedStargateMsg(decodedMessage)) { + const cwMsg = decodedStakingStargateMsgToCw(decodedMessage.stargate) + if (cwMsg) { + decodedMessage = cwMsg + } + } + + const stakeActions = getStakeActions(this.options.t) + const nativeToken = getNativeTokenForChainId(chainId) + + if ('distribution' in decodedMessage) { + return ( + objectMatchesStructure(decodedMessage, { + distribution: { + [StakingActionType.WithdrawDelegatorReward]: { + validator: {}, + }, + }, + }) || + objectMatchesStructure(decodedMessage, { + distribution: { + [StakingActionType.SetWithdrawAddress]: { + address: {}, + }, + }, + }) + ) + } else if ('staking' in decodedMessage) { + const action = stakeActions.find( + ({ type }) => type in decodedMessage.staking + ) + if (!action) { + return false + } + + const data = decodedMessage.staking[action.type] + return ( + ((action.type === StakingActionType.Redelegate && + objectMatchesStructure(data, { + src_validator: {}, + dst_validator: {}, + })) || + (action.type !== StakingActionType.Redelegate && + objectMatchesStructure(data, { + validator: {}, + }))) && + objectMatchesStructure(data, { + amount: { + amount: {}, + denom: {}, + }, + }) && + data.amount.denom === nativeToken.denomOrAddress + ) } + + return false } - return { - key: ActionKey.ManageStaking, - Icon: DepositEmoji, - label: t('title.manageStaking'), - description: t('info.manageStakingDescription'), - keywords: [ - 'stake', - 'unstake', - 'restake', - 'delegate', - 'undelegate', - 'redelegate', - ], - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, + decode([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): ManageStakingData { + // Convert to CW msg format to use consistent logic below. + if (isDecodedStargateMsg(decodedMessage)) { + const cwMsg = decodedStakingStargateMsgToCw(decodedMessage.stargate) + if (cwMsg) { + decodedMessage = cwMsg + } + } + + const stakeActions = getStakeActions(this.options.t) + const nativeToken = getNativeTokenForChainId(chainId) + + if ('distribution' in decodedMessage) { + if ( + StakingActionType.WithdrawDelegatorReward in decodedMessage.distribution + ) { + return { + chainId, + type: StakingActionType.WithdrawDelegatorReward, + validator: + decodedMessage.distribution.withdraw_delegator_reward.validator, + // Default values, not needed for displaying this type of message. + toValidator: '', + amount: 1, + withdrawAddress: '', + } + } else if ( + StakingActionType.SetWithdrawAddress in decodedMessage.distribution + ) { + return { + chainId, + type: StakingActionType.SetWithdrawAddress, + withdrawAddress: + decodedMessage.distribution.set_withdraw_address.address, + validator: '', + toValidator: '', + amount: 1, + } + } + } else if ('staking' in decodedMessage) { + const action = stakeActions.find( + ({ type }) => type in decodedMessage.staking + ) + // Should never happen as this is validated in match. + if (!action) { + throw new Error('Invalid staking message') + } + + const data = decodedMessage.staking[action.type] + + return { + chainId, + type: action.type, + validator: + action.type === StakingActionType.Redelegate + ? data.src_validator + : data.validator, + toValidator: + action.type === StakingActionType.Redelegate + ? data.dst_validator + : '', + amount: convertMicroDenomToDenomWithDecimals( + data.amount, + nativeToken.decimals + ), + withdrawAddress: '', + } + } + + // Should never happen as this is validated in match. + throw new Error('Invalid staking message') } } diff --git a/packages/stateful/actions/core/dao_governance/ManageStorageItems/Component.stories.tsx b/packages/stateful/actions/core/actions/ManageStorageItems/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/dao_governance/ManageStorageItems/Component.stories.tsx rename to packages/stateful/actions/core/actions/ManageStorageItems/Component.stories.tsx diff --git a/packages/stateful/actions/core/dao_governance/ManageStorageItems/Component.tsx b/packages/stateful/actions/core/actions/ManageStorageItems/Component.tsx similarity index 100% rename from packages/stateful/actions/core/dao_governance/ManageStorageItems/Component.tsx rename to packages/stateful/actions/core/actions/ManageStorageItems/Component.tsx diff --git a/packages/stateful/actions/core/dao_governance/ManageStorageItems/README.md b/packages/stateful/actions/core/actions/ManageStorageItems/README.md similarity index 100% rename from packages/stateful/actions/core/dao_governance/ManageStorageItems/README.md rename to packages/stateful/actions/core/actions/ManageStorageItems/README.md diff --git a/packages/stateful/actions/core/actions/ManageStorageItems/index.tsx b/packages/stateful/actions/core/actions/ManageStorageItems/index.tsx new file mode 100644 index 000000000..f7f59ee1a --- /dev/null +++ b/packages/stateful/actions/core/actions/ManageStorageItems/index.tsx @@ -0,0 +1,138 @@ +import { DaoDaoCoreSelectors } from '@dao-dao/state' +import { + ActionBase, + WrenchEmoji, + useActionOptions, + useCachedLoadingWithError, +} from '@dao-dao/stateless' +import { Feature, UnifiedCosmosMsg } from '@dao-dao/types' +import { + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { + makeExecuteSmartContractMessage, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { + ManageStorageItemsData, + ManageStorageItemsComponent as StatelessManageStorageItemsComponent, +} from './Component' + +const Component: ActionComponent = ( + props +) => { + const { + address, + chain: { chain_id: chainId }, + } = useActionOptions() + + const existingItems = useCachedLoadingWithError( + DaoDaoCoreSelectors.listAllItemsSelector({ + contractAddress: address, + chainId, + }) + ) + + return ( + + ) +} + +export class ManageStorageItemsAction extends ActionBase { + public readonly key = ActionKey.ManageStorageItems + public readonly Component = Component + + protected _defaults: ManageStorageItemsData = { + setting: true, + key: '', + value: '', + } + + private valueKey: string + + constructor(options: ActionOptions) { + if (options.context.type !== ActionContextType.Dao) { + throw new Error('Not DAO context') + } + + super(options, { + Icon: WrenchEmoji, + label: options.t('title.manageStorageItems'), + description: options.t('info.manageStorageItemsDescription'), + // other actions, like manage widgets, should be matched before this + matchPriority: -90, + }) + + this.valueKey = options.context.dao.info.supportedFeatures[ + Feature.StorageItemValueKey + ] + ? 'value' + : 'addr' + } + + encode({ setting, key, value }: ManageStorageItemsData): UnifiedCosmosMsg { + return makeExecuteSmartContractMessage({ + chainId: this.options.chain.chain_id, + sender: this.options.address, + contractAddress: this.options.address, + msg: setting + ? { + set_item: { + key, + [this.valueKey]: value, + }, + } + : { + remove_item: { + key, + }, + }, + }) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return ( + objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: {}, + }, + }, + }) && + decodedMessage.wasm.execute.contract_addr === this.options.address && + ('set_item' in decodedMessage.wasm.execute.msg || + 'remove_item' in decodedMessage.wasm.execute.msg) + ) + } + + decode([{ decodedMessage }]: ProcessedMessage[]): ManageStorageItemsData { + const setting = 'set_item' in decodedMessage.wasm.execute.msg + + return { + setting, + key: + (setting + ? decodedMessage.wasm.execute.msg.set_item.key + : decodedMessage.wasm.execute.msg.remove_item.key) ?? '', + value: setting + ? decodedMessage.wasm.execute.msg.set_item[this.valueKey] + : decodedMessage.wasm.execute.msg.remove_item[this.valueKey], + } + } +} diff --git a/packages/stateful/actions/core/dao_governance/ManageSubDaoPause/Component.stories.tsx b/packages/stateful/actions/core/actions/ManageSubDaoPause/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/dao_governance/ManageSubDaoPause/Component.stories.tsx rename to packages/stateful/actions/core/actions/ManageSubDaoPause/Component.stories.tsx diff --git a/packages/stateful/actions/core/dao_governance/ManageSubDaoPause/Component.tsx b/packages/stateful/actions/core/actions/ManageSubDaoPause/Component.tsx similarity index 98% rename from packages/stateful/actions/core/dao_governance/ManageSubDaoPause/Component.tsx rename to packages/stateful/actions/core/actions/ManageSubDaoPause/Component.tsx index 2ebe95acc..5bece8211 100644 --- a/packages/stateful/actions/core/dao_governance/ManageSubDaoPause/Component.tsx +++ b/packages/stateful/actions/core/actions/ManageSubDaoPause/Component.tsx @@ -9,13 +9,12 @@ import { Loader, NumberInput, SegmentedControlsTitle, + useActionOptions, } from '@dao-dao/stateless' import { LoadingData, StatefulEntityDisplayProps } from '@dao-dao/types' import { ActionComponent } from '@dao-dao/types/actions' import { NEUTRON_GOVERNANCE_DAO } from '@dao-dao/utils' -import { useActionOptions } from '../../../react' - export type ManageSubDaoPauseData = { pausing: boolean address: string diff --git a/packages/stateful/actions/core/dao_governance/ManageSubDaoPause/README.md b/packages/stateful/actions/core/actions/ManageSubDaoPause/README.md similarity index 100% rename from packages/stateful/actions/core/dao_governance/ManageSubDaoPause/README.md rename to packages/stateful/actions/core/actions/ManageSubDaoPause/README.md diff --git a/packages/stateful/actions/core/actions/ManageSubDaoPause/index.tsx b/packages/stateful/actions/core/actions/ManageSubDaoPause/index.tsx new file mode 100644 index 000000000..810f80cd4 --- /dev/null +++ b/packages/stateful/actions/core/actions/ManageSubDaoPause/index.tsx @@ -0,0 +1,154 @@ +import { useQueryClient } from '@tanstack/react-query' + +import { contractQueries, daoQueries } from '@dao-dao/state' +import { ActionBase, PlayPauseEmoji } from '@dao-dao/stateless' +import { ChainId, UnifiedCosmosMsg } from '@dao-dao/types' +import { + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { + NEUTRON_GOVERNANCE_DAO, + NEUTRON_SECURITY_SUBDAO, + NEUTRON_SUBDAO_CORE_CONTRACT_NAMES, + makeExecuteSmartContractMessage, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { EntityDisplay } from '../../../../components' +import { useQueryLoadingData } from '../../../../hooks' +import { ManageSubDaoPauseComponent, ManageSubDaoPauseData } from './Component' + +const Component: ActionComponent = ( + props +) => { + const queryClient = useQueryClient() + const neutronSubdaos = useQueryLoadingData( + daoQueries.listAllSubDaos(queryClient, { + chainId: ChainId.NeutronMainnet, + address: NEUTRON_GOVERNANCE_DAO, + }), + [], + { + transform: (subDaos) => subDaos.map(({ addr }) => addr), + } + ) + + return ( + + ) +} + +export class ManageSubDaoPauseAction extends ActionBase { + public readonly key = ActionKey.ManageSubDaoPause + public readonly Component = Component + + protected _defaults: ManageSubDaoPauseData = { + address: '', + pausing: true, + pauseBlocks: 0, + } + + constructor(options: ActionOptions) { + if ( + options.chain.chain_id !== ChainId.NeutronMainnet || + options.context.type !== ActionContextType.Dao || + (options.address !== NEUTRON_GOVERNANCE_DAO && + options.address !== NEUTRON_SECURITY_SUBDAO) + ) { + throw new Error( + 'Only main Neutron DAO or its security subDAO can manage SubDAO pauses.' + ) + } + + super(options, { + Icon: PlayPauseEmoji, + label: options.t('title.manageSubDaoPause'), + description: options.t('info.manageSubDaoPauseDescription'), + }) + } + + encode({ + address, + pausing, + pauseBlocks, + }: ManageSubDaoPauseData): UnifiedCosmosMsg { + return makeExecuteSmartContractMessage({ + chainId: this.options.chain.chain_id, + sender: this.options.address, + contractAddress: address, + msg: pausing + ? { + pause: { + duration: pauseBlocks, + }, + } + : { + unpause: {}, + }, + }) + } + + async match([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): Promise { + return ( + (objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + pause: { + duration: {}, + }, + }, + }, + }, + }) || + objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + unpause: {}, + }, + }, + }, + })) && + (await this.options.queryClient.fetchQuery( + contractQueries.isContract(this.options.queryClient, { + chainId, + address: decodedMessage.wasm.execute.contract_addr, + nameOrNames: NEUTRON_SUBDAO_CORE_CONTRACT_NAMES, + }) + )) + ) + } + + decode([{ decodedMessage }]: ProcessedMessage[]): ManageSubDaoPauseData { + const pausing = objectMatchesStructure(decodedMessage.wasm.execute.msg, { + pause: {}, + }) + + return { + address: decodedMessage.wasm.execute.contract_addr, + pausing, + pauseBlocks: pausing ? decodedMessage.wasm.execute.msg.pause.duration : 0, + } + } +} diff --git a/packages/stateful/actions/core/subdaos/ManageSubDaos/Component.stories.tsx b/packages/stateful/actions/core/actions/ManageSubDaos/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/subdaos/ManageSubDaos/Component.stories.tsx rename to packages/stateful/actions/core/actions/ManageSubDaos/Component.stories.tsx diff --git a/packages/stateful/actions/core/subdaos/ManageSubDaos/Component.tsx b/packages/stateful/actions/core/actions/ManageSubDaos/Component.tsx similarity index 99% rename from packages/stateful/actions/core/subdaos/ManageSubDaos/Component.tsx rename to packages/stateful/actions/core/actions/ManageSubDaos/Component.tsx index b0d45253e..0eecc31fc 100644 --- a/packages/stateful/actions/core/subdaos/ManageSubDaos/Component.tsx +++ b/packages/stateful/actions/core/actions/ManageSubDaos/Component.tsx @@ -9,6 +9,7 @@ import { IconButton, InputErrorMessage, InputLabel, + useActionOptions, } from '@dao-dao/stateless' import { ActionComponent, @@ -18,8 +19,6 @@ import { import { SubDao } from '@dao-dao/types/contracts/DaoDaoCore' import { makeValidateAddress, validateRequired } from '@dao-dao/utils' -import { useActionOptions } from '../../../react' - export type ManageSubDaosData = { toAdd: SubDao[] toRemove: { address: string }[] diff --git a/packages/stateful/actions/core/subdaos/ManageSubDaos/README.md b/packages/stateful/actions/core/actions/ManageSubDaos/README.md similarity index 100% rename from packages/stateful/actions/core/subdaos/ManageSubDaos/README.md rename to packages/stateful/actions/core/actions/ManageSubDaos/README.md diff --git a/packages/stateful/actions/core/actions/ManageSubDaos/index.tsx b/packages/stateful/actions/core/actions/ManageSubDaos/index.tsx new file mode 100644 index 000000000..0cae3fa45 --- /dev/null +++ b/packages/stateful/actions/core/actions/ManageSubDaos/index.tsx @@ -0,0 +1,123 @@ +import { useRecoilValue } from 'recoil' + +import { DaoDaoCoreSelectors } from '@dao-dao/state' +import { ActionBase, FamilyEmoji, useActionOptions } from '@dao-dao/stateless' +import { + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + Feature, + ProcessedMessage, + UnifiedCosmosMsg, +} from '@dao-dao/types' +import { + makeExecuteSmartContractMessage, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { AddressInput, EntityDisplay } from '../../../../components' +import { + ManageSubDaosData, + ManageSubDaosComponent as StatelessManageSubDaosComponent, +} from './Component' + +const Component: ActionComponent = (props) => { + const { + address, + chain: { chain_id: chainId }, + } = useActionOptions() + + const currentSubDaos = useRecoilValue( + DaoDaoCoreSelectors.allSubDaoConfigsSelector({ + chainId, + contractAddress: address, + }) + ) + + return ( + + ) +} + +export class ManageSubDaosAction extends ActionBase { + public readonly key = ActionKey.ManageSubDaos + public readonly Component = Component + + protected _defaults: ManageSubDaosData = { + toAdd: [ + { + addr: '', + }, + ], + toRemove: [], + } + + constructor(options: ActionOptions) { + if (options.context.type !== ActionContextType.Dao) { + throw new Error('Only DAOs can manage their subDAOs.') + } + + if (!options.context.dao.info.supportedFeatures[Feature.SubDaos]) { + throw new Error("This DAO's version doesn't support subDAOs.") + } + + super(options, { + Icon: FamilyEmoji, + label: options.t('title.manageSubDaos'), + description: options.t('info.manageSubDaosActionDescription'), + }) + } + + encode({ toAdd, toRemove }: ManageSubDaosData): UnifiedCosmosMsg { + return makeExecuteSmartContractMessage({ + chainId: this.options.chain.chain_id, + sender: this.options.address, + contractAddress: this.options.address, + msg: { + update_sub_daos: { + to_add: toAdd, + to_remove: toRemove.map(({ address }) => address), + }, + }, + }) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return ( + objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + update_sub_daos: { + to_add: {}, + to_remove: {}, + }, + }, + }, + }, + }) && decodedMessage.wasm.execute.contract_addr === this.options.address + ) + } + + decode([{ decodedMessage }]: ProcessedMessage[]): ManageSubDaosData { + return { + toAdd: decodedMessage.wasm.execute.msg.update_sub_daos.to_add, + toRemove: decodedMessage.wasm.execute.msg.update_sub_daos.to_remove.map( + (address: string) => ({ + address, + }) + ), + } + } +} diff --git a/packages/stateful/actions/core/treasury/ManageVesting/BeginVesting.stories.tsx b/packages/stateful/actions/core/actions/ManageVesting/BeginVesting.stories.tsx similarity index 97% rename from packages/stateful/actions/core/treasury/ManageVesting/BeginVesting.stories.tsx rename to packages/stateful/actions/core/actions/ManageVesting/BeginVesting.stories.tsx index 4576b96eb..33f1fac75 100644 --- a/packages/stateful/actions/core/treasury/ManageVesting/BeginVesting.stories.tsx +++ b/packages/stateful/actions/core/actions/ManageVesting/BeginVesting.stories.tsx @@ -58,7 +58,7 @@ Default.args = { }, balance: '1248281239056', owner: { - type: AccountType.Native, + type: AccountType.Base, chainId: CHAIN_ID, address: 'owner', }, @@ -79,7 +79,7 @@ Default.args = { }, balance: '19023827587124', owner: { - type: AccountType.Native, + type: AccountType.Base, chainId: CHAIN_ID, address: 'owner', }, diff --git a/packages/stateful/actions/core/treasury/ManageVesting/BeginVesting.tsx b/packages/stateful/actions/core/actions/ManageVesting/BeginVesting.tsx similarity index 95% rename from packages/stateful/actions/core/treasury/ManageVesting/BeginVesting.tsx rename to packages/stateful/actions/core/actions/ManageVesting/BeginVesting.tsx index 8ab2cf774..4f98191ac 100644 --- a/packages/stateful/actions/core/treasury/ManageVesting/BeginVesting.tsx +++ b/packages/stateful/actions/core/actions/ManageVesting/BeginVesting.tsx @@ -29,11 +29,12 @@ import { TextInput, TokenInput, VestingStepsLineGraph, + useActionOptions, + useInitializedActionForKey, } from '@dao-dao/stateless' import { ActionChainContextType, ActionComponent, - ActionContextType, ActionKey, AddressInputProps, CreateCw1Whitelist, @@ -43,6 +44,7 @@ import { GenericTokenBalanceWithOwner, LoadingDataWithError, StatefulEntityDisplayProps, + TokenType, VestingContractVersion, VestingPaymentsWidgetData, VestingStep, @@ -61,11 +63,10 @@ import { validateRequired, } from '@dao-dao/utils' -import { useActionForKey, useActionOptions } from '../../../react/context' - export type BeginVestingData = { chainId: string amount: number + type: TokenType denomOrAddress: string recipient: string title: string @@ -242,14 +243,9 @@ export const BeginVesting: ActionComponent = ({ ) const selectedSymbol = selectedToken?.token?.symbol ?? t('info.tokens') - const insufficientBalanceI18nKey = - context.type === ActionContextType.Wallet - ? 'error.insufficientWalletBalance' - : 'error.cantSpendMoreThanTreasury' - - const configureVestingPaymentActionDefaults = useActionForKey( + const configureVestingPaymentAction = useInitializedActionForKey( ActionKey.ConfigureVestingPayments - )?.useDefaults() + ) // If widget not set up, don't render anything because begin vesting cannot be // used. @@ -288,12 +284,27 @@ export const BeginVesting: ActionComponent = ({ ), ] + // A warning if the amount is too high. We don't want to make this an error + // because often people want to spend funds that a previous action makes + // available, so just show a warning. + const insufficientFundsWarning = + watchAmount > selectedBalance + ? t('error.insufficientFundsWarning', { + amount: selectedBalance.toLocaleString(undefined, { + maximumFractionDigits: selectedDecimals, + }), + tokenSymbol: + selectedToken?.token.symbol ?? t('info.token').toLocaleUpperCase(), + }) + : undefined + return (
{isCreating && !vestingManagerExists && - configureVestingPaymentActionDefaults && ( + !configureVestingPaymentAction.loading && + !configureVestingPaymentAction.errored && ( = ({ addAction( { actionKey: ActionKey.ConfigureVestingPayments, - data: configureVestingPaymentActionDefaults, + data: configureVestingPaymentAction.data.defaults, }, actionIndex ) @@ -359,23 +370,11 @@ export const BeginVesting: ActionComponent = ({ fieldName: (fieldNamePrefix + 'amount') as 'amount', error: errors?.amount, min: convertMicroDenomToDenomWithDecimals(1, selectedDecimals), - max: selectedBalance, step: convertMicroDenomToDenomWithDecimals(1, selectedDecimals), - validations: [ - (amount) => - amount <= selectedBalance || - t(insufficientBalanceI18nKey, { - amount: selectedBalance.toLocaleString(undefined, { - maximumFractionDigits: selectedDecimals, - }), - tokenSymbol: - selectedToken?.token.symbol ?? - t('info.token').toLocaleUpperCase(), - }), - ], }} - onSelectToken={({ chainId, denomOrAddress }) => { + onSelectToken={({ chainId, type, denomOrAddress }) => { setValue((fieldNamePrefix + 'chainId') as 'chainId', chainId) + setValue((fieldNamePrefix + 'type') as 'type', type) setValue( (fieldNamePrefix + 'denomOrAddress') as 'denomOrAddress', denomOrAddress @@ -425,11 +424,15 @@ export const BeginVesting: ActionComponent = ({
- {(errors?.amount || errors?.denomOrAddress || errors?.recipient) && ( + {(errors?.amount || + errors?.denomOrAddress || + errors?.recipient || + insufficientFundsWarning) && (
+
)}
diff --git a/packages/stateful/actions/core/treasury/ManageVesting/CancelVesting.tsx b/packages/stateful/actions/core/actions/ManageVesting/CancelVesting.tsx similarity index 99% rename from packages/stateful/actions/core/treasury/ManageVesting/CancelVesting.tsx rename to packages/stateful/actions/core/actions/ManageVesting/CancelVesting.tsx index 69bf24780..8c8486833 100644 --- a/packages/stateful/actions/core/treasury/ManageVesting/CancelVesting.tsx +++ b/packages/stateful/actions/core/actions/ManageVesting/CancelVesting.tsx @@ -8,6 +8,7 @@ import { InputErrorMessage, Loader, TokenAmountDisplay, + useActionOptions, } from '@dao-dao/stateless' import { ActionComponent, @@ -23,8 +24,6 @@ import { getChainAddressForActionOptions, } from '@dao-dao/utils' -import { useActionOptions } from '../../../react' - export type CancelVestingData = { chainId: string address: string diff --git a/packages/stateful/actions/core/treasury/ManageVesting/README.md b/packages/stateful/actions/core/actions/ManageVesting/README.md similarity index 100% rename from packages/stateful/actions/core/treasury/ManageVesting/README.md rename to packages/stateful/actions/core/actions/ManageVesting/README.md diff --git a/packages/stateful/actions/core/treasury/ManageVesting/RegisterSlash.tsx b/packages/stateful/actions/core/actions/ManageVesting/RegisterSlash.tsx similarity index 99% rename from packages/stateful/actions/core/treasury/ManageVesting/RegisterSlash.tsx rename to packages/stateful/actions/core/actions/ManageVesting/RegisterSlash.tsx index 7c24fbc69..ab22a0283 100644 --- a/packages/stateful/actions/core/treasury/ManageVesting/RegisterSlash.tsx +++ b/packages/stateful/actions/core/actions/ManageVesting/RegisterSlash.tsx @@ -11,6 +11,7 @@ import { SelectCircle, Table, TokenAmountDisplay, + useActionOptions, } from '@dao-dao/stateless' import { ActionComponent, @@ -27,8 +28,6 @@ import { getNativeTokenForChainId, } from '@dao-dao/utils' -import { useActionOptions } from '../../../react/context' - export type RegisterSlashData = { chainId: string address: string diff --git a/packages/stateful/actions/core/actions/ManageVesting/index.tsx b/packages/stateful/actions/core/actions/ManageVesting/index.tsx new file mode 100644 index 000000000..741332e41 --- /dev/null +++ b/packages/stateful/actions/core/actions/ManageVesting/index.tsx @@ -0,0 +1,1063 @@ +import { coins } from '@cosmjs/amino' +import { useQueries, useQueryClient } from '@tanstack/react-query' +import { ComponentType, useEffect } from 'react' +import { useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' + +import { + chainQueries, + cw1WhitelistExtraQueries, + cwPayrollFactoryQueries, + cwVestingExtraQueries, + tokenQueries, +} from '@dao-dao/state/query' +import { + ActionBase, + Loader, + MoneyWingsEmoji, + SegmentedControls, + useActionOptions, +} from '@dao-dao/stateless' +import { + DurationUnits, + DurationWithUnits, + SegmentedControlsProps, + TokenType, + TypedOption, + UnifiedCosmosMsg, + VestingContractVersion, + VestingInfo, + VestingPaymentsWidgetData, + WidgetId, +} from '@dao-dao/types' +import { + ActionComponent, + ActionComponentProps, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { + ExecuteMsg, + InstantiateNativePayrollContractMsg, +} from '@dao-dao/types/contracts/CwPayrollFactory' +import { InstantiateMsg as VestingInstantiateMsg } from '@dao-dao/types/contracts/CwVesting' +import { + chainIsIndexed, + convertDenomToMicroDenomWithDecimals, + convertDurationWithUnitsToSeconds, + convertMicroDenomToDenomWithDecimals, + convertSecondsToDurationWithUnits, + decodeJsonFromBase64, + encodeJsonToBase64, + getChainAddressForActionOptions, + getDaoWidgets, + getDisplayNameForChainId, + getNativeTokenForChainId, + isValidBech32Address, + makeCombineQueryResultsIntoLoadingDataWithError, + makeExecuteSmartContractMessage, + maybeMakePolytoneExecuteMessages, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { + AddressInput, + EntityDisplay, + SuspenseLoader, + Trans, + VestingPaymentCard, +} from '../../../../components' +import { + useCreateCw1Whitelist, + useQueryLoadingData, + useQueryLoadingDataWithError, +} from '../../../../hooks' +import { useWidget } from '../../../../widgets' +import { useTokenBalances } from '../../../hooks/useTokenBalances' +import { BeginVesting, BeginVestingData } from './BeginVesting' +import { CancelVesting, CancelVestingData } from './CancelVesting' +import { RegisterSlash, RegisterSlashData } from './RegisterSlash' + +export type ManageVestingData = { + mode: 'begin' | 'cancel' | 'registerSlash' + begin: BeginVestingData + cancel: CancelVestingData + registerSlash: RegisterSlashData +} + +const instantiateStructure = { + instantiate_msg: { + denom: {}, + recipient: {}, + schedule: {}, + title: {}, + total: {}, + unbonding_duration_seconds: {}, + vesting_duration_seconds: {}, + }, + label: {}, +} + +/** + * Get vesting sources from widget data. + */ +const getVestingSourcesFromWidgetData = ( + options: ActionOptions, + widgetData: VestingPaymentsWidgetData +) => + widgetData.factories + ? Object.fromEntries( + Object.entries(widgetData.factories).map( + ([chainId, { address: factory, version }]) => [ + chainId, + { + owner: getChainAddressForActionOptions(options, chainId) || '', + factory, + version, + }, + ] + ) + ) + : // If the factories are undefined, this DAO is using an old version + // of the vesting widget which only allows a single factory on the + // same chain as the DAO. If widget data is undefined, this is being + // used by a wallet. + { + [options.chain.chain_id]: { + owner: options.address, + factory: widgetData.factory, + version: widgetData.version, + }, + } + +/** + * Get queries for the vesting infos owned by (and thus can be canceled by) the + * current entity using this action, unless the chain is not indexed, in which + * case fetch from the registered factories. These vests may or may not have + * been created by the current entity, since someone can set another entity as + * an owner/canceler of a vesting contract. + */ +const getVestingInfosOwnedByEntityQueries = ( + options: ActionOptions, + widgetData?: VestingPaymentsWidgetData +) => { + const sources = + widgetData && getVestingSourcesFromWidgetData(options, widgetData) + return options.context.accounts.flatMap(({ chainId, address }) => + chainIsIndexed(chainId) + ? cwVestingExtraQueries.vestingInfosOwnedBy(options.queryClient, { + address, + chainId, + }) + : // Fallback to factory query for this chain if no indexer. This is limited as vesting payments created by other entities will not load, even if the current entity has the power to cancel. + sources?.[chainId]?.factory + ? cwVestingExtraQueries.vestingInfosForFactory(options.queryClient, { + chainId, + address: sources[chainId].factory!, + }) + : [] + ) +} + +/** + * Get the vesting infos owned by (and thus can be canceled by) the current + * entity executing an action. These may or may not have been created by the + * current entity, since someone can set another entity as an owner/canceler of + * a vesting contract. + */ +const useVestingInfosOwnedByEntity = () => { + const options = useActionOptions() + return useQueries({ + queries: getVestingInfosOwnedByEntityQueries(options), + combine: makeCombineQueryResultsIntoLoadingDataWithError({ + transform: (infos) => infos.flat(), + }), + }) +} + +const Component: ComponentType< + ActionComponentProps & { + widgetData?: VestingPaymentsWidgetData + } +> = ({ widgetData, ...props }) => { + const { t } = useTranslation() + const { + chain: { chain_id: nativeChainId }, + } = useActionOptions() + + const { setValue, watch, setError, clearErrors, trigger } = + useFormContext() + const mode = watch((props.fieldNamePrefix + 'mode') as 'mode') + const selectedChainId = + mode === 'begin' + ? watch((props.fieldNamePrefix + 'begin.chainId') as 'begin.chainId') + : mode === 'registerSlash' + ? watch( + (props.fieldNamePrefix + + 'registerSlash.chainId') as 'registerSlash.chainId' + ) + : mode === 'cancel' + ? watch((props.fieldNamePrefix + 'cancel.chainId') as 'cancel.chainId') + : undefined + const selectedAddress = + mode === 'registerSlash' + ? watch( + (props.fieldNamePrefix + + 'registerSlash.address') as 'registerSlash.address' + ) + : mode === 'cancel' + ? watch((props.fieldNamePrefix + 'cancel.address') as 'cancel.address') + : undefined + const beginOwnerMode = watch( + (props.fieldNamePrefix + 'begin.ownerMode') as 'begin.ownerMode' + ) + const beginManyOwnersCw1WhitelistContract = watch( + (props.fieldNamePrefix + + 'begin.manyOwnersCw1WhitelistContract') as 'begin.manyOwnersCw1WhitelistContract' + ) + + const tokenBalances = useTokenBalances() + + // Only used on pre-v1 vesting widgets. + const queryClient = useQueryClient() + const preV1VestingFactoryOwner = useQueryLoadingDataWithError( + widgetData && !widgetData.version && widgetData.factory + ? cwPayrollFactoryQueries.ownership(queryClient, { + chainId: nativeChainId, + contractAddress: widgetData.factory, + }) + : undefined, + ({ owner }) => owner || null + ) + + const vestingInfos = useVestingInfosOwnedByEntity() + + const didSelectVest = + !props.isCreating && + (mode === 'registerSlash' || mode === 'cancel') && + !!selectedChainId && + !!selectedAddress + const selectedVest = useQueryLoadingData( + didSelectVest + ? cwVestingExtraQueries.info(queryClient, { + chainId: selectedChainId, + address: selectedAddress, + }) + : undefined, + undefined as VestingInfo | undefined + ) + + // Prevent action from being submitted if no address is selected while we're + // registering slash or cancelling. + useEffect(() => { + if (mode !== 'registerSlash' && mode !== 'cancel') { + clearErrors( + (props.fieldNamePrefix + + 'registerSlash.address') as 'registerSlash.address' + ) + clearErrors( + (props.fieldNamePrefix + 'cancel.address') as 'cancel.address' + ) + return + } + // Make sure to clear errors for other modes on switch. + else if (mode === 'registerSlash') { + clearErrors( + (props.fieldNamePrefix + 'cancel.address') as 'cancel.address' + ) + } else if (mode === 'cancel') { + clearErrors( + (props.fieldNamePrefix + + 'registerSlash.address') as 'registerSlash.address' + ) + } + + if (!selectedAddress || !isValidBech32Address(selectedAddress)) { + setError( + (props.fieldNamePrefix + `${mode}.address`) as `${typeof mode}.address`, + { + type: 'manual', + message: t('error.noVestingContractSelected'), + } + ) + } else { + clearErrors( + (props.fieldNamePrefix + `${mode}.address`) as `${typeof mode}.address` + ) + } + }, [setError, clearErrors, props.fieldNamePrefix, t, mode, selectedAddress]) + + const tabs: SegmentedControlsProps['tabs'] = [ + // Only allow beginning a vest if widget is setup. + ...(widgetData + ? ([ + { + label: t('title.beginVesting'), + value: 'begin', + }, + ] as TypedOption[]) + : []), + { + label: t('title.cancelVesting'), + value: 'cancel', + }, + { + label: t('title.registerSlash'), + value: 'registerSlash', + }, + ] + const selectedTab = tabs.find((tab) => tab.value === mode) + + const { + creatingCw1Whitelist: creatingCw1WhitelistOwners, + createCw1Whitelist: createCw1WhitelistOwners, + } = useCreateCw1Whitelist({ + // Trigger veto address field validations. + validation: async () => { + if (beginOwnerMode !== 'many') { + throw new Error(t('error.unexpectedError')) + } + + await trigger( + (props.fieldNamePrefix + 'begin.manyOwners') as 'begin.manyOwners', + { + shouldFocus: true, + } + ) + }, + contractLabel: 'Vesting Multi-Owner cw1-whitelist', + }) + + // Prevent action from being submitted if the cw1-whitelist contract has not + // yet been created and it needs to be. + useEffect(() => { + if (beginOwnerMode === 'many' && !beginManyOwnersCw1WhitelistContract) { + setError( + (props.fieldNamePrefix + + 'begin.manyOwnersCw1WhitelistContract') as 'begin.manyOwnersCw1WhitelistContract', + { + type: 'manual', + message: t('error.accountListNeedsSaving'), + } + ) + } else { + clearErrors( + (props.fieldNamePrefix + + 'begin.manyOwnersCw1WhitelistContract') as 'begin.manyOwnersCw1WhitelistContract' + ) + } + }, [ + setError, + clearErrors, + t, + beginOwnerMode, + beginManyOwnersCw1WhitelistContract, + props.fieldNamePrefix, + ]) + + return ( + } + forceFallback={ + // Manually trigger loader. + tokenBalances.loading + } + > + {props.isCreating ? ( + + className="mb-2" + onSelect={(value) => + setValue((props.fieldNamePrefix + 'mode') as 'mode', value) + } + selected={mode} + tabs={tabs} + /> + ) : ( +

{selectedTab?.label}

+ )} + + {mode === 'begin' ? ( + + ) : mode === 'registerSlash' ? ( + + ) : mode === 'cancel' ? ( + + ) : null} +
+ ) +} + +// Only check if widget exists in DAOs. +const DaoComponent: ActionComponent = (props) => { + const widgetData = useWidget( + WidgetId.VestingPayments + )?.daoWidget.values + + return +} + +const WalletComponent: ActionComponent = ( + props +) => + +export class ManageVestingAction extends ActionBase { + public readonly key = ActionKey.ManageVesting + public readonly Component: ActionComponent + + private vestingInfosOwnedByEntity: VestingInfo[] = [] + private widgetData?: VestingPaymentsWidgetData + + constructor(options: ActionOptions) { + super(options, { + Icon: MoneyWingsEmoji, + label: options.t('title.manageVesting'), + description: options.t('info.manageVestingDescription'), + // Hide until ready. Update this in setup. + hideFromPicker: true, + }) + + this.Component = + options.context.type === ActionContextType.Dao + ? DaoComponent + : WalletComponent + + this.widgetData = + options.context.type === ActionContextType.Dao + ? getDaoWidgets(options.context.dao.info.items).find( + ({ id }) => id === WidgetId.VestingPayments + )?.values + : undefined + + // Fire async init immediately since we may hide this action. + this.init().catch(() => {}) + } + + async setup() { + this.vestingInfosOwnedByEntity = ( + await Promise.all( + getVestingInfosOwnedByEntityQueries(this.options).map((query) => + this.options.queryClient.fetchQuery(query) + ) + ) + ).flat() + + // Don't show if vesting payment widget is not enabled (for DAOs) and this + // entity owns no vesting payments. + this.metadata.hideFromPicker = + (this.options.context.type !== ActionContextType.Dao || + !this.widgetData) && + this.vestingInfosOwnedByEntity.length === 0 + + // Default start to 7 days from now. + const start = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) + + this.defaults = { + // Cannot use begin if no widget setup, so default to cancel if no data. + mode: this.widgetData ? 'begin' : 'cancel', + begin: { + chainId: this.options.chain.chain_id, + amount: 1, + type: TokenType.Native, + denomOrAddress: getNativeTokenForChainId(this.options.chain.chain_id) + .denomOrAddress, + recipient: '', + startDate: `${start.toISOString().split('T')[0]} 12:00 AM`, + title: '', + ownerMode: 'me', + otherOwner: '', + manyOwners: [ + { + address: this.options.address, + }, + { + address: '', + }, + ], + manyOwnersCw1WhitelistContract: '', + steps: [ + { + percent: 100, + delay: { + value: 1, + units: DurationUnits.Years, + }, + }, + ], + }, + cancel: { + chainId: this.options.chain.chain_id, + address: '', + }, + registerSlash: { + chainId: this.options.chain.chain_id, + address: '', + validator: '', + time: '', + amount: '', + duringUnbonding: false, + }, + } + } + + async encode({ + mode, + begin, + registerSlash, + cancel, + }: ManageVestingData): Promise { + let chainId: string + let cosmosMsg: UnifiedCosmosMsg + + // Can only begin a vest if there is widget data available. + if (mode === 'begin' && this.widgetData) { + chainId = begin.chainId + + const vestingSource = getVestingSourcesFromWidgetData( + this.options, + this.widgetData + )[chainId] + if (!vestingSource?.factory) { + throw new Error( + this.options.t('error.noChainVestingManager', { + chain: getDisplayNameForChainId(chainId), + }) + ) + } + + const [nativeUnstakingDurationSeconds, token, preV1VestingFactoryOwner] = + await Promise.all([ + this.options.queryClient.fetchQuery( + chainQueries.nativeUnstakingDurationSeconds({ + chainId, + }) + ), + this.options.queryClient.fetchQuery( + tokenQueries.info(this.options.queryClient, { + chainId, + type: begin.type, + denomOrAddress: begin.denomOrAddress, + }) + ), + // Pre-v1 vesting widgets use the factory owner as the vesting owner. + this.widgetData.factory && !this.widgetData.version + ? this.options.queryClient + .fetchQuery( + cwPayrollFactoryQueries.ownership(this.options.queryClient, { + chainId: this.options.chain.chain_id, + contractAddress: this.widgetData.factory, + }) + ) + .then(({ owner }) => owner || null) + : null, + ]) + + const total = convertDenomToMicroDenomWithDecimals( + begin.amount, + token.decimals + ) + + const vestingDurationSeconds = begin.steps.reduce( + (acc, { delay }) => acc + convertDurationWithUnitsToSeconds(delay), + 0 + ) + + const instantiateMsg: VestingInstantiateMsg = { + denom: + token.type === TokenType.Native + ? { + native: token.denomOrAddress, + } + : { + cw20: token.denomOrAddress, + }, + description: begin.description || undefined, + owner: + // Widgets prior to V1 use the factory owner. + !vestingSource.version + ? preV1VestingFactoryOwner + : // V1 and later can set the owner, or no widget data (when used by a wallet). + !this.widgetData || + (vestingSource.version && + vestingSource.version >= VestingContractVersion.V1) + ? begin.ownerMode === 'none' + ? undefined + : begin.ownerMode === 'me' + ? vestingSource.owner + : begin.ownerMode === 'other' + ? begin.otherOwner + : begin.ownerMode === 'many' + ? begin.manyOwnersCw1WhitelistContract + : vestingSource.owner + : vestingSource.owner, + recipient: begin.recipient, + schedule: + begin.steps.length === 1 + ? 'saturating_linear' + : { + piecewise_linear: [ + // First point must be 0 amount at 1 second. + [1, '0'], + ...(begin.steps.reduce((acc, { percent, delay }, index) => { + const delaySeconds = Math.max( + // Ensure this is at least 1 second since it can't have + // overlapping points. + 1, + convertDurationWithUnitsToSeconds(delay) - + // For the first step, subtract 1 second since the first + // point must start at 1 second and is hardcoded above. + (index === 0 ? 1 : 0) + ) + + // For the first step, start at 1 second since the first + // point must start at 1 second and is hardcoded above. + const lastSeconds = index === 0 ? 1 : acc[acc.length - 1][0] + const lastAmount = + index === 0 ? '0' : acc[acc.length - 1][1] + + return [ + ...acc, + [ + lastSeconds + delaySeconds, + BigInt( + // For the last step, use total to avoid rounding + // issues. + index === begin.steps.length - 1 + ? total + : Math.round( + Number(lastAmount) + + (percent / 100) * Number(total) + ) + ).toString(), + ], + ] + }, [] as [number, string][]) as [number, string][]), + ], + }, + start_time: + begin.startDate && !isNaN(Date.parse(begin.startDate)) + ? // milliseconds => nanoseconds + BigInt( + Math.round(new Date(begin.startDate).getTime() * 1e6) + ).toString() + : '', + title: begin.title, + total: BigInt(total).toString(), + unbonding_duration_seconds: + token.type === TokenType.Native && + token.denomOrAddress === + getNativeTokenForChainId(chainId).denomOrAddress + ? nativeUnstakingDurationSeconds + : 0, + vesting_duration_seconds: vestingDurationSeconds, + } + + const msg: InstantiateNativePayrollContractMsg = { + instantiate_msg: instantiateMsg, + label: `vest_to_${begin.recipient}_${Date.now()}`, + } + + if (token.type === TokenType.Native) { + cosmosMsg = makeExecuteSmartContractMessage({ + chainId, + contractAddress: vestingSource.factory, + sender: vestingSource.owner, + msg: { + instantiate_native_payroll_contract: msg, + } as ExecuteMsg, + funds: coins(total, token.denomOrAddress), + }) + } else if (token.type === TokenType.Cw20) { + // Execute CW20 send message. + cosmosMsg = makeExecuteSmartContractMessage({ + chainId, + contractAddress: token.denomOrAddress, + sender: vestingSource.owner, + msg: { + send: { + amount: BigInt(total).toString(), + contract: vestingSource.factory, + msg: encodeJsonToBase64({ + instantiate_payroll_contract: msg, + }), + }, + }, + }) + } else { + throw new Error(this.options.t('error.unexpectedError')) + } + } else if (mode === 'cancel' || mode === 'registerSlash') { + chainId = mode === 'cancel' ? cancel.chainId : registerSlash.chainId + + const contractAddress = + mode === 'cancel' ? cancel.address : registerSlash.address + + const vestingInfo = this.vestingInfosOwnedByEntity.find( + ({ vestingContractAddress }) => + vestingContractAddress === contractAddress + ) + if (!vestingInfo) { + throw new Error(this.options.t('error.noVestingContractSelected')) + } + + const from = getChainAddressForActionOptions(this.options, chainId) + if (!from) { + throw new Error(this.options.t('error.loadingData')) + } + + const viaCw1Whitelist = + !!vestingInfo.owner?.isCw1Whitelist && + vestingInfo.owner.cw1WhitelistAdmins.includes(from) + + const msg = makeExecuteSmartContractMessage({ + chainId, + contractAddress, + sender: viaCw1Whitelist ? vestingInfo.owner!.address : from, + msg: + mode === 'cancel' + ? { + cancel: {}, + } + : { + register_slash: { + validator: registerSlash.validator, + time: registerSlash.time, + amount: registerSlash.amount, + during_unbonding: registerSlash.duringUnbonding, + }, + }, + }) + + cosmosMsg = viaCw1Whitelist + ? // Wrap in cw1-whitelist execute. + makeExecuteSmartContractMessage({ + chainId, + contractAddress: vestingInfo.owner!.address, + sender: from, + msg: { + execute: { + msgs: [msg], + }, + }, + }) + : msg + } else { + throw new Error(this.options.t('error.unexpectedError')) + } + + return maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + chainId, + cosmosMsg + ) + } + + // helper to be used in match and decode + breakDownMessage({ decodedMessage, account: { chainId } }: ProcessedMessage) { + const isNativeBegin = + objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + instantiate_native_payroll_contract: instantiateStructure, + }, + }, + }, + }) && + decodedMessage.wasm.execute.funds.length === 1 && + objectMatchesStructure(decodedMessage.wasm.execute.funds[0], { + amount: {}, + denom: {}, + }) + + const isCw20Begin = + objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + send: { + amount: {}, + contract: {}, + msg: {}, + }, + }, + }, + }, + }) && + objectMatchesStructure( + decodeJsonFromBase64(decodedMessage.wasm.execute.msg.send.msg, true), + { + instantiate_payroll_contract: instantiateStructure, + } + ) + + const isRegisterSlash = objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + register_slash: { + validator: {}, + time: {}, + amount: {}, + during_unbonding: {}, + }, + }, + }, + }, + }) + + const isCancel = objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + cancel: {}, + }, + }, + }, + }) + + return { + chainId, + decodedMessage, + isNativeBegin, + isCw20Begin, + isRegisterSlash, + isCancel, + } + } + + match([message]: ProcessedMessage[]): ActionMatch { + const { isNativeBegin, isCw20Begin, isRegisterSlash, isCancel } = + this.breakDownMessage(message) + + return isNativeBegin || isCw20Begin || isRegisterSlash || isCancel + } + + async decode([message]: ProcessedMessage[]): Promise< + Partial + > { + const { + chainId, + decodedMessage, + isNativeBegin, + isCw20Begin, + isRegisterSlash, + isCancel, + } = this.breakDownMessage(message) + + if (isNativeBegin || isCw20Begin) { + const instantiateMsg: VestingInstantiateMsg = isNativeBegin + ? decodedMessage.wasm.execute.msg.instantiate_native_payroll_contract + .instantiate_msg + : // isCw20Begin + // Extract instantiate message from cw20 send message. + (decodeJsonFromBase64(decodedMessage.wasm.execute.msg.send.msg, true) + .instantiate_payroll_contract + ?.instantiate_msg as VestingInstantiateMsg) + + const [token, cw1WhitelistAdmins] = await Promise.all([ + this.options.queryClient.fetchQuery( + tokenQueries.info(this.options.queryClient, { + chainId, + type: isNativeBegin ? TokenType.Native : TokenType.Cw20, + denomOrAddress: isNativeBegin + ? decodedMessage.wasm.execute.funds[0].denom + : decodedMessage.wasm.execute.contract_addr, + }) + ), + // Attempt to load cw1-whitelist admins if the owner is set. Will only + // succeed if the owner is a cw1-whitelist contract. Otherwise it + // returns null. + instantiateMsg.owner + ? this.options.queryClient.fetchQuery( + cw1WhitelistExtraQueries.adminsIfCw1Whitelist( + this.options.queryClient, + { + chainId, + address: instantiateMsg.owner, + } + ) + ) + : null, + ]) + + const ownerMode = !instantiateMsg.owner + ? 'none' + : instantiateMsg.owner === + getChainAddressForActionOptions(this.options, chainId) + ? 'me' + : cw1WhitelistAdmins + ? 'many' + : 'other' + + return { + mode: 'begin', + begin: { + chainId, + type: token.type, + denomOrAddress: token.denomOrAddress, + description: instantiateMsg.description || undefined, + recipient: instantiateMsg.recipient, + startDate: instantiateMsg.start_time + ? new Date( + // nanoseconds => milliseconds + Number(instantiateMsg.start_time) / 1e6 + ).toISOString() + : '', + title: instantiateMsg.title, + amount: convertMicroDenomToDenomWithDecimals( + instantiateMsg.total, + token.decimals + ), + ownerMode, + otherOwner: (ownerMode === 'other' && instantiateMsg.owner) || '', + manyOwners: + ownerMode === 'many' && cw1WhitelistAdmins + ? cw1WhitelistAdmins.map((address) => ({ + address, + })) + : [], + manyOwnersCw1WhitelistContract: + (ownerMode === 'many' && instantiateMsg.owner) || '', + steps: + instantiateMsg.schedule === 'saturating_linear' + ? [ + { + percent: 100, + delay: convertSecondsToDurationWithUnits( + instantiateMsg.vesting_duration_seconds + ), + }, + ] + : instantiateMsg.schedule.piecewise_linear.reduce( + (acc, [seconds, amount], index) => { + // Ignore first step if hardcoded 0 amount at 1 second. + if (index === 0 && seconds === 1 && amount === '0') { + return acc + } + + const pastTimestamp = + index === 1 || + // Typecheck. Always false. + instantiateMsg!.schedule === 'saturating_linear' + ? // For first user-defined step, account for 1 second + // delay since we ignore the first hardcoded step at + // 1 second. When we created the msg, we subtracted + // 1 second from the first user-defined step's + // delay. + 0 + : instantiateMsg!.schedule.piecewise_linear[ + index - 1 + ][0] + const pastAmount = + index === 0 || + // Typecheck. Always false. + instantiateMsg!.schedule === 'saturating_linear' + ? '0' + : instantiateMsg!.schedule.piecewise_linear[ + index - 1 + ][1] + + return [ + ...acc, + { + percent: Number( + ( + ((Number(amount) - Number(pastAmount)) / + Number(instantiateMsg!.total)) * + 100 + ).toFixed(2) + ), + delay: convertSecondsToDurationWithUnits( + seconds - pastTimestamp + ), + }, + ] + }, + [] as { + percent: number + delay: DurationWithUnits + }[] + ), + }, + } + } else if (isRegisterSlash) { + return { + mode: 'registerSlash', + registerSlash: { + chainId, + address: decodedMessage.wasm.execute.contract_addr, + validator: decodedMessage.wasm.execute.msg.register_slash.validator, + time: decodedMessage.wasm.execute.msg.register_slash.time, + amount: decodedMessage.wasm.execute.msg.register_slash.amount, + duringUnbonding: + decodedMessage.wasm.execute.msg.register_slash.during_unbonding, + }, + } + } else if (isCancel) { + return { + mode: 'cancel', + cancel: { + chainId, + address: decodedMessage.wasm.execute.contract_addr, + }, + } + } + + // Should never happen. + throw new Error('Unexpected message') + } +} diff --git a/packages/stateful/actions/core/dao_governance/ManageVetoableDaos/Component.stories.tsx b/packages/stateful/actions/core/actions/ManageVetoableDaos/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/dao_governance/ManageVetoableDaos/Component.stories.tsx rename to packages/stateful/actions/core/actions/ManageVetoableDaos/Component.stories.tsx diff --git a/packages/stateful/actions/core/dao_governance/ManageVetoableDaos/Component.tsx b/packages/stateful/actions/core/actions/ManageVetoableDaos/Component.tsx similarity index 95% rename from packages/stateful/actions/core/dao_governance/ManageVetoableDaos/Component.tsx rename to packages/stateful/actions/core/actions/ManageVetoableDaos/Component.tsx index 70bfce8cd..0c88b77b9 100644 --- a/packages/stateful/actions/core/dao_governance/ManageVetoableDaos/Component.tsx +++ b/packages/stateful/actions/core/actions/ManageVetoableDaos/Component.tsx @@ -20,8 +20,8 @@ import { export type ManageVetoableDaosData = { chainId: string - address: string enable: boolean + address: string } export interface ManageVetoableDaosOptions { @@ -52,13 +52,6 @@ export const ManageVetoableDaosComponent: ActionComponent< return ( <> - {isCreating && ( - - )} -
+ {isCreating && ( + + )} + {!isCreating || enable ? ( <> diff --git a/packages/stateful/actions/core/dao_governance/ManageVetoableDaos/README.md b/packages/stateful/actions/core/actions/ManageVetoableDaos/README.md similarity index 100% rename from packages/stateful/actions/core/dao_governance/ManageVetoableDaos/README.md rename to packages/stateful/actions/core/actions/ManageVetoableDaos/README.md diff --git a/packages/stateful/actions/core/actions/ManageVetoableDaos/index.tsx b/packages/stateful/actions/core/actions/ManageVetoableDaos/index.tsx new file mode 100644 index 000000000..db63b7e84 --- /dev/null +++ b/packages/stateful/actions/core/actions/ManageVetoableDaos/index.tsx @@ -0,0 +1,126 @@ +import { daoVetoableDaosSelector } from '@dao-dao/state/recoil' +import { + ActionBase, + ThumbDownEmoji, + useActionOptions, + useCachedLoadingWithError, +} from '@dao-dao/stateless' +import { UnifiedCosmosMsg } from '@dao-dao/types' +import { + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { VETOABLE_DAOS_ITEM_KEY_PREFIX } from '@dao-dao/utils' + +import { AddressInput, EntityDisplay } from '../../../../components' +import { ManageStorageItemsAction } from '../ManageStorageItems' +import { + ManageVetoableDaosData, + ManageVetoableDaosComponent as StatelessManageVetoableDaosComponent, +} from './Component' + +const Component: ActionComponent = (props) => { + const { + address, + chain: { chain_id: chainId }, + } = useActionOptions() + + const currentlyEnabledLoading = useCachedLoadingWithError( + daoVetoableDaosSelector({ + chainId, + coreAddress: address, + }) + ) + + return ( + + ) +} + +export class ManageVetoableDaosAction extends ActionBase { + public readonly key = ActionKey.ManageVetoableDaos + public readonly Component = Component + + private manageStorageItemsAction: ManageStorageItemsAction + + constructor(options: ActionOptions) { + if (options.context.type !== ActionContextType.Dao) { + throw new Error('Not DAO context') + } + + const manageStorageItemsAction = new ManageStorageItemsAction(options) + + super(options, { + Icon: ThumbDownEmoji, + label: options.t('title.manageVetoableDaos'), + description: options.t('info.manageVetoableDaosDescription'), + // Match just before manage storage items since this action uses that + // under the hood. + matchPriority: manageStorageItemsAction.metadata.matchPriority! + 1, + }) + + this.manageStorageItemsAction = manageStorageItemsAction + + this.defaults = { + chainId: options.chain.chain_id, + enable: true, + address: '', + } + } + + setup() { + return this.manageStorageItemsAction.setup() + } + + encode({ + chainId, + enable, + address, + }: ManageVetoableDaosData): UnifiedCosmosMsg { + return this.manageStorageItemsAction.encode({ + setting: enable, + key: VETOABLE_DAOS_ITEM_KEY_PREFIX + chainId + ':' + address, + value: '1', + }) + } + + match(messages: ProcessedMessage[]): ActionMatch { + // Check if manage storage items matches. + const manageStorageItemsMatch = + this.manageStorageItemsAction.match(messages) + if (!manageStorageItemsMatch) { + return manageStorageItemsMatch + } + + // Ensure this is setting or removing a vetoable DAO item. + const { key, value } = this.manageStorageItemsAction.decode(messages) + return ( + key.startsWith(VETOABLE_DAOS_ITEM_KEY_PREFIX) && + key.split(':').length === 3 && + value === '1' + ) + } + + decode(messages: ProcessedMessage[]): ManageVetoableDaosData { + const { setting, key } = this.manageStorageItemsAction.decode(messages) + return { + chainId: key.split(':')[1], + enable: setting, + address: key.split(':')[2], + } + } +} diff --git a/packages/stateful/actions/core/dao_appearance/ManageWidgets/Component.stories.tsx b/packages/stateful/actions/core/actions/ManageWidgets/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/dao_appearance/ManageWidgets/Component.stories.tsx rename to packages/stateful/actions/core/actions/ManageWidgets/Component.stories.tsx diff --git a/packages/stateful/actions/core/dao_appearance/ManageWidgets/Component.tsx b/packages/stateful/actions/core/actions/ManageWidgets/Component.tsx similarity index 99% rename from packages/stateful/actions/core/dao_appearance/ManageWidgets/Component.tsx rename to packages/stateful/actions/core/actions/ManageWidgets/Component.tsx index e9be8cba9..c78cb3bfb 100644 --- a/packages/stateful/actions/core/dao_appearance/ManageWidgets/Component.tsx +++ b/packages/stateful/actions/core/actions/ManageWidgets/Component.tsx @@ -11,13 +11,12 @@ import { Loader, SegmentedControlsTitle, Tooltip, + useActionOptions, useUpdatingRef, } from '@dao-dao/stateless' import { DaoWidget, SuspenseLoaderProps, Widget } from '@dao-dao/types' import { ActionComponent } from '@dao-dao/types/actions' -import { useActionOptions } from '../../../react' - export type ManageWidgetsData = { mode: 'set' | 'delete' id: string diff --git a/packages/stateful/actions/core/dao_appearance/ManageWidgets/README.md b/packages/stateful/actions/core/actions/ManageWidgets/README.md similarity index 100% rename from packages/stateful/actions/core/dao_appearance/ManageWidgets/README.md rename to packages/stateful/actions/core/actions/ManageWidgets/README.md diff --git a/packages/stateful/actions/core/actions/ManageWidgets/index.tsx b/packages/stateful/actions/core/actions/ManageWidgets/index.tsx new file mode 100644 index 000000000..3ae9e3a6f --- /dev/null +++ b/packages/stateful/actions/core/actions/ManageWidgets/index.tsx @@ -0,0 +1,139 @@ +import { useMemo } from 'react' + +import { + ActionBase, + HammerAndWrenchEmoji, + Loader, + useActionOptions, +} from '@dao-dao/stateless' +import { DaoWidget, UnifiedCosmosMsg } from '@dao-dao/types' +import { + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { + DAO_WIDGET_ITEM_NAMESPACE, + getDaoWidgets, + getWidgetStorageItemKey, +} from '@dao-dao/utils' + +import { SuspenseLoader } from '../../../../components' +import { getWidgets, useWidgets } from '../../../../widgets' +import { ManageStorageItemsAction } from '../ManageStorageItems' +import { + ManageWidgetsData, + ManageWidgetsComponent as StatelessManageWidgetsComponent, +} from './Component' + +const Component: ActionComponent = (props) => { + const { + chain: { chain_id: chainId }, + } = useActionOptions() + const availableWidgets = useMemo(() => getWidgets(chainId), [chainId]) + const loadingExistingWidgets = useWidgets() + + return ( + } + forceFallback={loadingExistingWidgets.loading} + > + {!loadingExistingWidgets.loading && ( + daoWidget + ), + SuspenseLoader, + }} + /> + )} + + ) +} + +export class ManageWidgetsAction extends ActionBase { + public readonly key = ActionKey.ManageWidgets + public readonly Component = Component + + protected _defaults: ManageWidgetsData = { + mode: 'set', + id: '', + values: {}, + } + + public readonly availableWidgets: DaoWidget[] + private manageStorageItemsAction: ManageStorageItemsAction + + constructor(options: ActionOptions) { + if (options.context.type !== ActionContextType.Dao) { + throw new Error('Not DAO context') + } + + const manageStorageItemsAction = new ManageStorageItemsAction(options) + + super(options, { + Icon: HammerAndWrenchEmoji, + label: options.t('title.manageWidgets'), + description: options.t('info.manageWidgetsDescription'), + // match just before manage storage items, but still after other more + // individual widget actions, like enable vesting payments and enable + // retroactive compensation + matchPriority: manageStorageItemsAction.metadata.matchPriority! + 1, + }) + + this.manageStorageItemsAction = manageStorageItemsAction + this.availableWidgets = getDaoWidgets(options.context.dao.info.items) + } + + setup() { + return this.manageStorageItemsAction.setup() + } + + encode({ mode, id, values }: ManageWidgetsData): UnifiedCosmosMsg { + return this.manageStorageItemsAction.encode({ + setting: mode === 'set', + key: getWidgetStorageItemKey(id), + value: JSON.stringify(values), + }) + } + + match(messages: ProcessedMessage[]): ActionMatch { + const manageStorageItemsMatch = + this.manageStorageItemsAction.match(messages) + if (!manageStorageItemsMatch) { + return manageStorageItemsMatch + } + + // Ensure this is setting or removing a widget item. + const { key } = this.manageStorageItemsAction.decode(messages) + return key.startsWith(getWidgetStorageItemKey('')) + } + + decode(messages: ProcessedMessage[]): ManageWidgetsData { + const manageStorageItemsData = + this.manageStorageItemsAction.decode(messages) + + let values = {} + if (manageStorageItemsData.setting) { + try { + values = JSON.parse(manageStorageItemsData.value) + } catch (err) { + console.error(err) + } + } + + return { + mode: manageStorageItemsData.setting ? 'set' : 'delete', + id: manageStorageItemsData.key.substring( + DAO_WIDGET_ITEM_NAMESPACE.length + ), + values, + } + } +} diff --git a/packages/stateful/actions/core/smart_contracting/Migrate/Component.stories.tsx b/packages/stateful/actions/core/actions/Migrate/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/smart_contracting/Migrate/Component.stories.tsx rename to packages/stateful/actions/core/actions/Migrate/Component.stories.tsx diff --git a/packages/stateful/actions/core/smart_contracting/Migrate/Component.tsx b/packages/stateful/actions/core/actions/Migrate/Component.tsx similarity index 98% rename from packages/stateful/actions/core/smart_contracting/Migrate/Component.tsx rename to packages/stateful/actions/core/actions/Migrate/Component.tsx index 209c36c98..69c293442 100644 --- a/packages/stateful/actions/core/smart_contracting/Migrate/Component.tsx +++ b/packages/stateful/actions/core/actions/Migrate/Component.tsx @@ -8,6 +8,7 @@ import { InputLabel, NumberInput, StatusCard, + useActionOptions, useChain, } from '@dao-dao/stateless' import { ActionComponent } from '@dao-dao/types/actions' @@ -20,7 +21,6 @@ import { } from '@dao-dao/utils' import { Trans } from '../../../../components/Trans' -import { useActionOptions } from '../../../react/context' export interface MigrateOptions { onContractChange: (s: string) => void diff --git a/packages/stateful/actions/core/smart_contracting/Migrate/README.md b/packages/stateful/actions/core/actions/Migrate/README.md similarity index 100% rename from packages/stateful/actions/core/smart_contracting/Migrate/README.md rename to packages/stateful/actions/core/actions/Migrate/README.md diff --git a/packages/stateful/actions/core/actions/Migrate/index.tsx b/packages/stateful/actions/core/actions/Migrate/index.tsx new file mode 100644 index 000000000..1d94f71c8 --- /dev/null +++ b/packages/stateful/actions/core/actions/Migrate/index.tsx @@ -0,0 +1,185 @@ +import JSON5 from 'json5' +import { useState } from 'react' +import { useFormContext } from 'react-hook-form' +import { useRecoilValueLoadable } from 'recoil' + +import { contractAdminSelector } from '@dao-dao/state' +import { + ActionBase, + ChainProvider, + DaoSupportedChainPickerInput, + WhaleEmoji, + useActionOptions, +} from '@dao-dao/stateless' +import { UnifiedCosmosMsg, makeStargateMessage } from '@dao-dao/types' +import { + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { MsgMigrateContract as SecretMsgMigrateContract } from '@dao-dao/types/protobuf/codegen/secret/compute/v1beta1/msg' +import { + bech32AddressToBase64, + bech32DataToAddress, + decodeJsonFromBase64, + encodeJsonToBase64, + getChainAddressForActionOptions, + isDecodedStargateMsg, + isSecretNetwork, + makeWasmMessage, + maybeMakePolytoneExecuteMessages, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { MigrateContractComponent as StatelessMigrateContractComponent } from './Component' + +type MigrateData = { + chainId: string + contract: string + codeId: number + msg: string +} + +const Component: ActionComponent = (props) => { + const { context } = useActionOptions() + const { watch } = useFormContext() + const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') + + const [contract, setContract] = useState('') + + const admin = useRecoilValueLoadable( + contractAdminSelector({ + chainId, + contractAddress: contract, + }) + ) + + return ( + <> + {context.type === ActionContextType.Dao && ( + + )} + + + setContract(contract), + }} + /> + + + ) +} + +export class MigrateAction extends ActionBase { + public readonly key = ActionKey.Migrate + public readonly Component = Component + + constructor(options: ActionOptions) { + super(options, { + Icon: WhaleEmoji, + label: options.t('title.migrateSmartContract'), + description: options.t('info.migrateSmartContractActionDescription'), + // The upgrade action (and likely future upgrade actions) are a specific + // migrate action, so this needs to be after all those but before cross + // chain and ICA execute. + matchPriority: -90, + }) + + this.defaults = { + chainId: options.chain.chain_id, + contract: '', + codeId: 0, + msg: '{}', + } + } + + encode({ + chainId, + contract, + codeId, + msg: msgString, + }: MigrateData): UnifiedCosmosMsg[] { + const msg = JSON5.parse(msgString) + + return maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + chainId, + isSecretNetwork(chainId) + ? makeStargateMessage({ + stargate: { + typeUrl: SecretMsgMigrateContract.typeUrl, + value: SecretMsgMigrateContract.fromAmino({ + sender: bech32AddressToBase64( + getChainAddressForActionOptions(this.options, chainId) || '' + ), + contract: bech32AddressToBase64(contract), + code_id: BigInt(codeId).toString(), + msg: encodeJsonToBase64(msg), + }), + }, + }) + : makeWasmMessage({ + wasm: { + migrate: { + contract_addr: contract, + new_code_id: codeId, + msg, + }, + }, + }) + ) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return ( + isDecodedStargateMsg(decodedMessage, SecretMsgMigrateContract) || + objectMatchesStructure(decodedMessage, { + wasm: { + migrate: { + contract_addr: {}, + new_code_id: {}, + msg: {}, + }, + }, + }) + ) + } + + decode([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): MigrateData { + if (isDecodedStargateMsg(decodedMessage, SecretMsgMigrateContract)) { + return { + chainId, + contract: bech32DataToAddress( + chainId, + decodedMessage.stargate.value.contract + ), + codeId: Number(decodedMessage.stargate.value.codeId), + msg: JSON.stringify( + decodeJsonFromBase64(decodedMessage.stargate.value.msg) + ), + } + } else { + return { + chainId, + contract: decodedMessage.wasm.migrate.contract_addr, + codeId: decodedMessage.wasm.migrate.new_code_id, + msg: JSON.stringify(decodedMessage.wasm.migrate.msg, undefined, 2), + } + } + } +} diff --git a/packages/stateful/actions/core/nfts/MintNft/ChooseExistingNftCollection.tsx b/packages/stateful/actions/core/actions/MintNft/ChooseExistingNftCollection.tsx similarity index 97% rename from packages/stateful/actions/core/nfts/MintNft/ChooseExistingNftCollection.tsx rename to packages/stateful/actions/core/actions/MintNft/ChooseExistingNftCollection.tsx index 6cb9f5e9c..2997dc9ba 100644 --- a/packages/stateful/actions/core/nfts/MintNft/ChooseExistingNftCollection.tsx +++ b/packages/stateful/actions/core/actions/MintNft/ChooseExistingNftCollection.tsx @@ -4,11 +4,10 @@ import { useTranslation } from 'react-i18next' import { useRecoilCallback } from 'recoil' import { CommonNftSelectors, DaoDaoCoreSelectors } from '@dao-dao/state/recoil' -import { useCachedLoadable } from '@dao-dao/stateless' +import { useActionOptions, useCachedLoadable } from '@dao-dao/stateless' import { ActionComponent, ActionContextType } from '@dao-dao/types' import { objectMatchesStructure, processError } from '@dao-dao/utils' -import { useActionOptions } from '../../../react' import { ChooseExistingNftCollection as StatelessChooseExistingNftCollection } from './stateless/ChooseExistingNftCollection' import { MintNftData } from './types' diff --git a/packages/stateful/actions/core/nfts/MintNft/InstantiateNftCollection.tsx b/packages/stateful/actions/core/actions/MintNft/CreateNftCollection.tsx similarity index 87% rename from packages/stateful/actions/core/nfts/MintNft/InstantiateNftCollection.tsx rename to packages/stateful/actions/core/actions/MintNft/CreateNftCollection.tsx index 0f52328ba..356e43211 100644 --- a/packages/stateful/actions/core/nfts/MintNft/InstantiateNftCollection.tsx +++ b/packages/stateful/actions/core/actions/MintNft/CreateNftCollection.tsx @@ -3,7 +3,7 @@ import { useFormContext } from 'react-hook-form' import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' -import { useSupportedChainContext } from '@dao-dao/stateless' +import { useActionOptions, useSupportedChainContext } from '@dao-dao/stateless' import { ActionComponent, ActionContextType, ActionKey } from '@dao-dao/types' import { getChainAddressForActionOptions, @@ -11,12 +11,12 @@ import { processError, } from '@dao-dao/utils' +import { CreateNftCollectionAction } from '../../../../components/nft/CreateNftCollectionAction' import { useWallet } from '../../../../hooks' -import { useActionOptions } from '../../../react' -import { InstantiateNftCollection as StatelessInstantiateNftCollection } from './stateless/InstantiateNftCollection' +import { CreateNftCollection as StatelessCreateNftCollection } from './stateless/CreateNftCollection' import { MintNftData } from './types' -export const InstantiateNftCollection: ActionComponent = (props) => { +export const CreateNftCollection: ActionComponent = (props) => { const { t } = useTranslation() const { watch, setValue } = useFormContext() const options = useActionOptions() @@ -105,11 +105,12 @@ export const InstantiateNftCollection: ActionComponent = (props) => { } return ( - ) diff --git a/packages/stateful/actions/core/nfts/MintNft/MintNft.tsx b/packages/stateful/actions/core/actions/MintNft/MintNft.tsx similarity index 97% rename from packages/stateful/actions/core/nfts/MintNft/MintNft.tsx rename to packages/stateful/actions/core/actions/MintNft/MintNft.tsx index 1ab6bf8d7..6a89b125c 100644 --- a/packages/stateful/actions/core/nfts/MintNft/MintNft.tsx +++ b/packages/stateful/actions/core/actions/MintNft/MintNft.tsx @@ -8,7 +8,7 @@ import { nftCardInfoWithUriSelector, nftUriDataSelector, } from '@dao-dao/state/recoil' -import { Loader, useCachedLoading } from '@dao-dao/stateless' +import { Loader, useActionOptions, useCachedLoading } from '@dao-dao/stateless' import { ActionComponent, ActionContextType, @@ -18,7 +18,6 @@ import { import { getChainForChainId, isValidBech32Address } from '@dao-dao/utils' import { AddressInput } from '../../../../components' -import { useActionOptions } from '../../../react' import { MintNft as StatelessMintNft } from './stateless/MintNft' import { MintNftData } from './types' diff --git a/packages/stateful/actions/core/nfts/MintNft/README.md b/packages/stateful/actions/core/actions/MintNft/README.md similarity index 100% rename from packages/stateful/actions/core/nfts/MintNft/README.md rename to packages/stateful/actions/core/actions/MintNft/README.md diff --git a/packages/stateful/actions/core/nfts/MintNft/index.tsx b/packages/stateful/actions/core/actions/MintNft/index.tsx similarity index 63% rename from packages/stateful/actions/core/nfts/MintNft/index.tsx rename to packages/stateful/actions/core/actions/MintNft/index.tsx index 8260da4a1..a68dcd6cc 100644 --- a/packages/stateful/actions/core/nfts/MintNft/index.tsx +++ b/packages/stateful/actions/core/actions/MintNft/index.tsx @@ -1,35 +1,35 @@ -import { useCallback, useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { + ActionBase, CameraWithFlashEmoji, ChainProvider, DaoSupportedChainPickerInput, InputErrorMessage, SegmentedControls, + useActionOptions, } from '@dao-dao/stateless' +import { UnifiedCosmosMsg } from '@dao-dao/types' import { ActionChainContextType, ActionComponent, ActionContextType, ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, + ActionMatch, + ActionOptions, + ProcessedMessage, } from '@dao-dao/types/actions' import { - decodePolytoneExecuteMsg, getChainAddressForActionOptions, - makeWasmMessage, - maybeMakePolytoneExecuteMessage, + makeExecuteSmartContractMessage, + maybeMakePolytoneExecuteMessages, objectMatchesStructure, } from '@dao-dao/utils' -import { useActionOptions } from '../../../react' import { ChooseExistingNftCollection } from './ChooseExistingNftCollection' -import { InstantiateNftCollection } from './InstantiateNftCollection' +import { CreateNftCollection } from './CreateNftCollection' import { MintNft } from './MintNft' import { UploadNftMetadata } from './stateless/UploadNftMetadata' import { MintNftData } from './types' @@ -136,7 +136,7 @@ const Component: ActionComponent = (props) => { /> {creatingNew ? ( - + ) : ( )} @@ -152,110 +152,105 @@ const Component: ActionComponent = (props) => { ) } -export const makeMintNftAction: ActionMaker = ({ - t, - address, - chain: { chain_id: currentChainId }, -}) => { - const useDefaults: UseDefaults = () => ({ - chainId: currentChainId, - contractChosen: false, - collectionAddress: undefined, +export class MintNftAction extends ActionBase { + public readonly key = ActionKey.MintNft + public readonly Component = Component - instantiateData: { - chainId: currentChainId, - name: '', - symbol: '', - }, - mintMsg: { - owner: address, - token_id: '', - token_uri: '', - }, - metadata: { - name: '', - description: '', - audio: undefined, - video: undefined, - extra: '{}', - }, - }) + constructor(options: ActionOptions) { + super(options, { + Icon: CameraWithFlashEmoji, + label: options.t('title.mintNft'), + description: options.t('info.mintNftDescription'), + // This must be after the Press widget's Create Post action. + matchPriority: -80, + }) - const useTransformToCosmos: UseTransformToCosmos = () => - useCallback(({ chainId, collectionAddress, mintMsg }: MintNftData) => { - // Should never happen if form validation is working correctly. - if (!collectionAddress) { - throw new Error(t('error.loadingData')) - } + this.defaults = { + chainId: options.chain.chain_id, + contractChosen: false, + collectionAddress: undefined, - return maybeMakePolytoneExecuteMessage( - currentChainId, - chainId, - makeWasmMessage({ - wasm: { - execute: { - contract_addr: collectionAddress, - funds: [], - msg: { - mint: mintMsg, - }, - }, - }, - }) - ) - }, []) + instantiateData: { + chainId: options.chain.chain_id, + name: '', + symbol: '', + }, + mintMsg: { + owner: options.address, + token_id: '', + token_uri: '', + }, + metadata: { + name: '', + description: '', + }, + } + } - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - let chainId = currentChainId - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg + encode({ + chainId, + collectionAddress, + mintMsg, + }: MintNftData): UnifiedCosmosMsg[] { + // Should never happen if form validation is working correctly. + if (!collectionAddress) { + throw new Error('Missing collection address.') } - return objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - mint: { - owner: {}, - token_id: {}, - token_uri: {}, - }, - }, + const sender = getChainAddressForActionOptions(this.options, chainId) + if (!sender) { + throw new Error('No sender found for chain.') + } + + return maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + chainId, + makeExecuteSmartContractMessage({ + chainId, + sender, + contractAddress: collectionAddress, + msg: { + mint: mintMsg, }, - }, - }) && msg.wasm.execute.msg.mint.token_uri - ? { - match: true, - data: { - chainId, - contractChosen: true, - collectionAddress: msg.wasm.execute.contract_addr, - mintMsg: { - owner: msg.wasm.execute.msg.mint.owner, - token_id: msg.wasm.execute.msg.mint.token_id, - token_uri: msg.wasm.execute.msg.mint.token_uri, + }) + ) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return ( + objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + mint: { + owner: {}, + token_id: {}, + token_uri: {}, + }, }, }, - } - : { - match: false, - } + }, + }) && !!decodedMessage.wasm.execute.msg.mint.token_uri + ) } - return { - key: ActionKey.MintNft, - Icon: CameraWithFlashEmoji, - label: t('title.mintNft'), - description: t('info.mintNftDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, + decode([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): MintNftData { + return { + chainId, + contractChosen: true, + collectionAddress: decodedMessage.wasm.execute.contract_addr, + mintMsg: { + owner: decodedMessage.wasm.execute.msg.mint.owner, + token_id: decodedMessage.wasm.execute.msg.mint.token_id, + token_uri: decodedMessage.wasm.execute.msg.mint.token_uri, + }, + } } } diff --git a/packages/stateful/actions/core/nfts/MintNft/stateless/ChooseExistingNftCollection.stories.tsx b/packages/stateful/actions/core/actions/MintNft/stateless/ChooseExistingNftCollection.stories.tsx similarity index 100% rename from packages/stateful/actions/core/nfts/MintNft/stateless/ChooseExistingNftCollection.stories.tsx rename to packages/stateful/actions/core/actions/MintNft/stateless/ChooseExistingNftCollection.stories.tsx diff --git a/packages/stateful/actions/core/nfts/MintNft/stateless/ChooseExistingNftCollection.tsx b/packages/stateful/actions/core/actions/MintNft/stateless/ChooseExistingNftCollection.tsx similarity index 100% rename from packages/stateful/actions/core/nfts/MintNft/stateless/ChooseExistingNftCollection.tsx rename to packages/stateful/actions/core/actions/MintNft/stateless/ChooseExistingNftCollection.tsx diff --git a/packages/stateful/actions/core/nfts/MintNft/stateless/InstantiateNftCollection.stories.tsx b/packages/stateful/actions/core/actions/MintNft/stateless/CreateNftCollection.stories.tsx similarity index 68% rename from packages/stateful/actions/core/nfts/MintNft/stateless/InstantiateNftCollection.stories.tsx rename to packages/stateful/actions/core/actions/MintNft/stateless/CreateNftCollection.stories.tsx index 231b3e3b1..937d936cf 100644 --- a/packages/stateful/actions/core/nfts/MintNft/stateless/InstantiateNftCollection.stories.tsx +++ b/packages/stateful/actions/core/actions/MintNft/stateless/CreateNftCollection.stories.tsx @@ -7,13 +7,14 @@ import { makeReactHookFormDecorator, } from '@dao-dao/storybook' +import { CreateNftCollectionAction } from '../../../../../components/nft/CreateNftCollectionAction' import { MintNftData } from '../types' -import { InstantiateNftCollection } from './InstantiateNftCollection' +import { CreateNftCollection } from './CreateNftCollection' export default { title: - 'DAO DAO / packages / stateful / actions / core / nfts / MintNft / stateless / InstantiateNftCollection', - component: InstantiateNftCollection, + 'DAO DAO / packages / stateful / actions / core / nfts / MintNft / stateless / CreateNftCollection', + component: CreateNftCollection, decorators: [ makeReactHookFormDecorator({ chainId: CHAIN_ID, @@ -29,11 +30,11 @@ export default { }), makeDaoProvidersDecorator(makeDaoInfo()), ], -} as ComponentMeta +} as ComponentMeta -const Template: ComponentStory = (args) => ( +const Template: ComponentStory = (args) => (
- +
) @@ -48,5 +49,6 @@ Default.args = { options: { onInstantiate: async () => alert('instantiate'), instantiating: false, + CreateNftCollectionAction, }, } diff --git a/packages/stateful/actions/core/nfts/MintNft/stateless/InstantiateNftCollection.tsx b/packages/stateful/actions/core/actions/MintNft/stateless/CreateNftCollection.tsx similarity index 83% rename from packages/stateful/actions/core/nfts/MintNft/stateless/InstantiateNftCollection.tsx rename to packages/stateful/actions/core/actions/MintNft/stateless/CreateNftCollection.tsx index 89da41742..10e0e9445 100644 --- a/packages/stateful/actions/core/nfts/MintNft/stateless/InstantiateNftCollection.tsx +++ b/packages/stateful/actions/core/actions/MintNft/stateless/CreateNftCollection.tsx @@ -4,11 +4,10 @@ import { useTranslation } from 'react-i18next' import { Button } from '@dao-dao/stateless' import { ActionComponent, ChainId } from '@dao-dao/types' -import { InstantiateNftCollectionAction } from '../../../../../components' import { InstantiateOptions, MintNftData } from '../types' -// Form displayed when the user is instantiating a new NFT collection. -export const InstantiateNftCollection: ActionComponent = ( +// Form displayed when the user is creating a new NFT collection. +export const CreateNftCollection: ActionComponent = ( props ) => { const { t } = useTranslation() @@ -20,7 +19,7 @@ export const InstantiateNftCollection: ActionComponent = ( return (
- Promise } diff --git a/packages/stateful/actions/core/dao_governance/NeutronOverruleSubDaoProposal/Component.stories.tsx b/packages/stateful/actions/core/actions/NeutronOverruleSubDaoProposal/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/dao_governance/NeutronOverruleSubDaoProposal/Component.stories.tsx rename to packages/stateful/actions/core/actions/NeutronOverruleSubDaoProposal/Component.stories.tsx diff --git a/packages/stateful/actions/core/dao_governance/NeutronOverruleSubDaoProposal/Component.tsx b/packages/stateful/actions/core/actions/NeutronOverruleSubDaoProposal/Component.tsx similarity index 93% rename from packages/stateful/actions/core/dao_governance/NeutronOverruleSubDaoProposal/Component.tsx rename to packages/stateful/actions/core/actions/NeutronOverruleSubDaoProposal/Component.tsx index 1ec99d33d..545e762e2 100644 --- a/packages/stateful/actions/core/dao_governance/NeutronOverruleSubDaoProposal/Component.tsx +++ b/packages/stateful/actions/core/actions/NeutronOverruleSubDaoProposal/Component.tsx @@ -2,15 +2,17 @@ import { ComponentType } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' -import { InputLabel, useDaoNavHelpers } from '@dao-dao/stateless' +import { + InputLabel, + useActionOptions, + useDaoNavHelpers, +} from '@dao-dao/stateless' import { StatefulEntityDisplayProps, StatefulProposalLineProps, } from '@dao-dao/types' import { ActionComponent } from '@dao-dao/types/actions' -import { useActionOptions } from '../../../react' - export type NeutronOverruleSubDaoProposalData = { coreAddress: string proposalId: string @@ -49,6 +51,7 @@ export const NeutronOverruleSubDaoProposalComponent: ActionComponent< chainId={chainId} coreAddress={coreAddress} isPreProposeProposal={false} + openInNewTab proposalId={proposalId} proposalViewUrl={getDaoProposalPath(coreAddress, proposalId)} /> diff --git a/packages/stateful/actions/core/dao_governance/NeutronOverruleSubDaoProposal/README.md b/packages/stateful/actions/core/actions/NeutronOverruleSubDaoProposal/README.md similarity index 100% rename from packages/stateful/actions/core/dao_governance/NeutronOverruleSubDaoProposal/README.md rename to packages/stateful/actions/core/actions/NeutronOverruleSubDaoProposal/README.md diff --git a/packages/stateful/actions/core/actions/NeutronOverruleSubDaoProposal/index.tsx b/packages/stateful/actions/core/actions/NeutronOverruleSubDaoProposal/index.tsx new file mode 100644 index 000000000..33772ac83 --- /dev/null +++ b/packages/stateful/actions/core/actions/NeutronOverruleSubDaoProposal/index.tsx @@ -0,0 +1,175 @@ +import { + contractQueries, + neutronCwdSubdaoTimelockSingleQueries, +} from '@dao-dao/state' +import { ActionBase, ThumbDownEmoji } from '@dao-dao/stateless' +import { ChainId, ContractVersion, PreProposeModuleType } from '@dao-dao/types' +import { + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { ContractName, objectMatchesStructure } from '@dao-dao/utils' + +import { EntityDisplay, ProposalLine } from '../../../../components' +import { daoQueries } from '../../../../queries' +import { + NeutronOverruleSubDaoProposalData, + NeutronOverruleSubDaoProposalComponent as StatelessNeutronOverruleSubDaoProposalComponent, +} from './Component' + +const Component: ActionComponent< + undefined, + NeutronOverruleSubDaoProposalData +> = (props) => ( + +) + +export class NeutronOverruleSubDaoProposalAction extends ActionBase { + public readonly key = ActionKey.NeutronOverruleSubDaoProposal + public readonly Component = Component + + protected _defaults: NeutronOverruleSubDaoProposalData = { + coreAddress: '', + proposalId: '', + } + + constructor(options: ActionOptions) { + // Only usable in Neutron-fork SubDAOs. + if ( + options.chain.chain_id !== ChainId.NeutronMainnet || + options.context.type !== ActionContextType.Dao || + options.context.dao.coreVersion !== ContractVersion.V2AlphaNeutronFork + ) { + throw new Error('Only Neutron-forked SubDAOs can overrule proposals.') + } + + super(options, { + Icon: ThumbDownEmoji, + label: options.t('title.overruleSubDaoProposal'), + description: options.t('info.overruleSubDaoProposalDescription'), + // Don't allow selecting in picker since Neutron fork DAO overrule + // proposals are automatically created. This is just an action to render + // them. + hideFromPicker: true, + }) + } + + // This action is just for rendering Neutron fork DAO overrule proposals. + encode() { + return [] + } + + async match([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): Promise { + if ( + !objectMatchesStructure( + decodedMessage, + { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + overrule_proposal: { + proposal_id: {}, + }, + }, + }, + }, + } || + !(await this.options.queryClient.fetchQuery( + contractQueries.isContract(this.options.queryClient, { + chainId: this.options.chain.chain_id, + address: decodedMessage.wasm.execute.contract_addr, + nameOrNames: ContractName.NeutronCwdSubdaoTimelockSingle, + }) + )) + ) + ) { + return false + } + + // Get SubDAO from the timelock module used in the message. + const { subdao } = await this.options.queryClient.fetchQuery( + neutronCwdSubdaoTimelockSingleQueries.config(this.options.queryClient, { + chainId, + contractAddress: decodedMessage.wasm.execute.contract_addr, + }) + ) + + // Get SubDAO proposal modules. + const proposalModules = await this.options.queryClient.fetchQuery( + daoQueries.proposalModules(this.options.queryClient, { + chainId, + coreAddress: subdao, + }) + ) + + // Get proposal module that uses the specified timelock module. + const proposalModule = proposalModules.find( + ({ prePropose }) => + prePropose?.type === PreProposeModuleType.NeutronSubdaoSingle && + prePropose.config.timelockAddress === + decodedMessage.wasm.execute.contract_addr + ) + + return !!proposalModule + } + + async decode([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): Promise { + // Get SubDAO from the timelock module used in the message. + const { subdao } = await this.options.queryClient.fetchQuery( + neutronCwdSubdaoTimelockSingleQueries.config(this.options.queryClient, { + chainId, + contractAddress: decodedMessage.wasm.execute.contract_addr, + }) + ) + + // Get SubDAO proposal modules. + const proposalModules = await this.options.queryClient.fetchQuery( + daoQueries.proposalModules(this.options.queryClient, { + chainId, + coreAddress: subdao, + }) + ) + + // Get proposal module that uses the specified timelock module. + const proposalModule = proposalModules.find( + ({ prePropose }) => + prePropose?.type === PreProposeModuleType.NeutronSubdaoSingle && + prePropose.config.timelockAddress === + decodedMessage.wasm.execute.contract_addr + ) + + // Should never happen as this is validated in match. + if (!proposalModule) { + throw new Error('Proposal module not found.') + } + + return { + coreAddress: subdao, + proposalId: + proposalModule.prefix + + decodedMessage.wasm.execute.msg.overrule_proposal.proposal_id, + } + } +} diff --git a/packages/stateful/actions/core/valence/PauseRebalancer/Component.stories.tsx b/packages/stateful/actions/core/actions/PauseRebalancer/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/valence/PauseRebalancer/Component.stories.tsx rename to packages/stateful/actions/core/actions/PauseRebalancer/Component.stories.tsx diff --git a/packages/stateful/actions/core/valence/PauseRebalancer/Component.tsx b/packages/stateful/actions/core/actions/PauseRebalancer/Component.tsx similarity index 100% rename from packages/stateful/actions/core/valence/PauseRebalancer/Component.tsx rename to packages/stateful/actions/core/actions/PauseRebalancer/Component.tsx diff --git a/packages/stateful/actions/core/valence/PauseRebalancer/README.md b/packages/stateful/actions/core/actions/PauseRebalancer/README.md similarity index 100% rename from packages/stateful/actions/core/valence/PauseRebalancer/README.md rename to packages/stateful/actions/core/actions/PauseRebalancer/README.md diff --git a/packages/stateful/actions/core/actions/PauseRebalancer/index.tsx b/packages/stateful/actions/core/actions/PauseRebalancer/index.tsx new file mode 100644 index 000000000..9609b1fa9 --- /dev/null +++ b/packages/stateful/actions/core/actions/PauseRebalancer/index.tsx @@ -0,0 +1,88 @@ +import { ActionBase, PlayPauseEmoji } from '@dao-dao/stateless' +import { AccountType, ChainId, UnifiedCosmosMsg } from '@dao-dao/types' +import { + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { + getAccountAddress, + makeExecuteSmartContractMessage, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { + PauseRebalancerComponent as Component, + PauseRebalancerData, +} from './Component' + +export class PauseRebalancerAction extends ActionBase { + public readonly key = ActionKey.PauseRebalancer + public readonly Component = Component + + constructor(options: ActionOptions) { + super(options, { + Icon: PlayPauseEmoji, + label: options.t('title.pauseRebalancer'), + description: options.t('info.pauseRebalancerDescription'), + // Hide if no Valence account created. + hideFromPicker: !options.context.accounts.some( + ({ type }) => type === AccountType.Valence + ), + }) + + const account = getAccountAddress({ + accounts: options.context.accounts, + chainId: ChainId.NeutronMainnet, + types: [AccountType.Valence], + }) + + if (!account) { + throw new Error(options.t('error.noValenceAccount')) + } + + this.defaults = { + account, + } + } + + encode({ account }: PauseRebalancerData): UnifiedCosmosMsg { + return makeExecuteSmartContractMessage({ + chainId: this.options.chain.chain_id, + sender: this.options.address, + contractAddress: account, + msg: { + pause_service: { + service_name: 'rebalancer', + }, + }, + }) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return ( + objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + pause_service: { + service_name: {}, + }, + }, + }, + }, + }) && + decodedMessage.wasm.execute.msg.pause_service.service_name === + 'rebalancer' + ) + } + + decode([{ decodedMessage }]: ProcessedMessage[]): PauseRebalancerData { + return { + account: decodedMessage.wasm.execute.contract_addr, + } + } +} diff --git a/packages/stateful/actions/core/valence/ResumeRebalancer/Component.stories.tsx b/packages/stateful/actions/core/actions/ResumeRebalancer/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/valence/ResumeRebalancer/Component.stories.tsx rename to packages/stateful/actions/core/actions/ResumeRebalancer/Component.stories.tsx diff --git a/packages/stateful/actions/core/valence/ResumeRebalancer/Component.tsx b/packages/stateful/actions/core/actions/ResumeRebalancer/Component.tsx similarity index 100% rename from packages/stateful/actions/core/valence/ResumeRebalancer/Component.tsx rename to packages/stateful/actions/core/actions/ResumeRebalancer/Component.tsx diff --git a/packages/stateful/actions/core/valence/ResumeRebalancer/README.md b/packages/stateful/actions/core/actions/ResumeRebalancer/README.md similarity index 100% rename from packages/stateful/actions/core/valence/ResumeRebalancer/README.md rename to packages/stateful/actions/core/actions/ResumeRebalancer/README.md diff --git a/packages/stateful/actions/core/actions/ResumeRebalancer/index.tsx b/packages/stateful/actions/core/actions/ResumeRebalancer/index.tsx new file mode 100644 index 000000000..881a91db9 --- /dev/null +++ b/packages/stateful/actions/core/actions/ResumeRebalancer/index.tsx @@ -0,0 +1,88 @@ +import { ActionBase, PlayPauseEmoji } from '@dao-dao/stateless' +import { AccountType, ChainId, UnifiedCosmosMsg } from '@dao-dao/types' +import { + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { + getAccountAddress, + makeExecuteSmartContractMessage, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { + ResumeRebalancerComponent as Component, + ResumeRebalancerData, +} from './Component' + +export class ResumeRebalancerAction extends ActionBase { + public readonly key = ActionKey.ResumeRebalancer + public readonly Component = Component + + constructor(options: ActionOptions) { + super(options, { + Icon: PlayPauseEmoji, + label: options.t('title.resumeRebalancer'), + description: options.t('info.resumeRebalancerDescription'), + // Hide if no Valence account created. + hideFromPicker: !options.context.accounts.some( + ({ type }) => type === AccountType.Valence + ), + }) + + const account = getAccountAddress({ + accounts: options.context.accounts, + chainId: ChainId.NeutronMainnet, + types: [AccountType.Valence], + }) + + if (!account) { + throw new Error(options.t('error.noValenceAccount')) + } + + this.defaults = { + account, + } + } + + encode({ account }: ResumeRebalancerData): UnifiedCosmosMsg { + return makeExecuteSmartContractMessage({ + chainId: this.options.chain.chain_id, + sender: this.options.address, + contractAddress: account, + msg: { + resume_service: { + service_name: 'rebalancer', + }, + }, + }) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return ( + objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + resume_service: { + service_name: {}, + }, + }, + }, + }, + }) && + decodedMessage.wasm.execute.msg.resume_service.service_name === + 'rebalancer' + ) + } + + decode([{ decodedMessage }]: ProcessedMessage[]): ResumeRebalancerData { + return { + account: decodedMessage.wasm.execute.contract_addr, + } + } +} diff --git a/packages/stateful/actions/core/dao_governance/SetUpApprover/Component.tsx b/packages/stateful/actions/core/actions/SetUpApprover/Component.tsx similarity index 64% rename from packages/stateful/actions/core/dao_governance/SetUpApprover/Component.tsx rename to packages/stateful/actions/core/actions/SetUpApprover/Component.tsx index e862d9bed..8d5ff7979 100644 --- a/packages/stateful/actions/core/dao_governance/SetUpApprover/Component.tsx +++ b/packages/stateful/actions/core/actions/SetUpApprover/Component.tsx @@ -2,8 +2,16 @@ import { ComponentType } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' -import { Loader, RadioInput, useDaoInfoContext } from '@dao-dao/stateless' -import { LoadingData, StatefulEntityDisplayProps } from '@dao-dao/types' +import { + ErrorPage, + Loader, + RadioInput, + useDaoInfoContext, +} from '@dao-dao/stateless' +import { + LoadingDataWithError, + StatefulEntityDisplayProps, +} from '@dao-dao/types' import { ActionComponent } from '@dao-dao/types/actions' export type SetUpApproverData = { @@ -13,7 +21,7 @@ export type SetUpApproverData = { } export type SetUpApproverOptions = { - options: LoadingData< + options: LoadingDataWithError< { dao: string preProposeAddress: string @@ -43,15 +51,19 @@ export const SetUpApproverComponent: ActionComponent = ({

{isCreating && !options.loading ? ( - ({ - display: , - value: preProposeAddress, - }))} - setValue={setValue} - watch={watch} - /> + options.errored ? ( + + ) : ( + ({ + display: , + value: preProposeAddress, + }))} + setValue={setValue} + watch={watch} + /> + ) ) : dao ? ( ) : ( diff --git a/packages/stateful/actions/core/dao_governance/SetUpApprover/README.md b/packages/stateful/actions/core/actions/SetUpApprover/README.md similarity index 100% rename from packages/stateful/actions/core/dao_governance/SetUpApprover/README.md rename to packages/stateful/actions/core/actions/SetUpApprover/README.md diff --git a/packages/stateful/actions/core/actions/SetUpApprover/index.tsx b/packages/stateful/actions/core/actions/SetUpApprover/index.tsx new file mode 100644 index 000000000..514b7764a --- /dev/null +++ b/packages/stateful/actions/core/actions/SetUpApprover/index.tsx @@ -0,0 +1,273 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' +import { useFormContext } from 'react-hook-form' + +import { daoQueries } from '@dao-dao/state/query' +import { DaoPreProposeApprovalSingleSelectors } from '@dao-dao/state/recoil' +import { + ActionBase, + PersonRaisingHandEmoji, + useActionOptions, + useCachedLoading, +} from '@dao-dao/stateless' +import { + Feature, + ModuleInstantiateInfo, + UnifiedCosmosMsg, +} from '@dao-dao/types' +import { + ActionChainContextType, + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { InstantiateMsg as DaoPreProposeApproverInstantiateMsg } from '@dao-dao/types/contracts/DaoPreProposeApprover' +import { + InstantiateMsg as DaoProposalSingleInstantiateMsg, + Config as SingleChoiceConfig, +} from '@dao-dao/types/contracts/DaoProposalSingle.v2' +import { Config as SecretSingleChoiceConfig } from '@dao-dao/types/contracts/SecretDaoProposalSingle' +import { + DaoProposalSingleAdapterId, + decodeJsonFromBase64, + encodeJsonToBase64, + makeExecuteSmartContractMessage, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { + SecretSingleChoiceProposalModule, + SingleChoiceProposalModule, +} from '../../../../clients' +import { EntityDisplay } from '../../../../components/EntityDisplay' +import { useQueryLoadingDataWithError } from '../../../../hooks' +import { + SetUpApproverData, + SetUpApproverComponent as StatelessComponent, +} from './Component' + +const Component: ActionComponent = (props) => { + const { + address, + chain: { chain_id: chainId }, + } = useActionOptions() + + const { watch, setValue } = useFormContext() + const preProposeApprovalSingle = watch( + (props.fieldNamePrefix + 'address') as 'address' + ) + // When creating, load DAO address from pre-propose module address. + const dao = useCachedLoading( + !props.isCreating && preProposeApprovalSingle + ? DaoPreProposeApprovalSingleSelectors.daoSelector({ + chainId, + contractAddress: preProposeApprovalSingle, + params: [], + }) + : undefined, + undefined + ) + useEffect(() => { + if (!props.isCreating && !dao.loading && dao.data) { + setValue((props.fieldNamePrefix + 'dao') as 'dao', dao.data) + } + }, [dao, props.fieldNamePrefix, props.isCreating, setValue]) + + const queryClient = useQueryClient() + const options = useQueryLoadingDataWithError( + daoQueries.listPotentialApprovalDaos(queryClient, { + chainId, + address, + }) + ) + + return ( + + ) +} + +export class SetUpApproverAction extends ActionBase { + public readonly key = ActionKey.SetUpApprover + public readonly Component = Component + + protected _defaults: SetUpApproverData = { + address: '', + } + + constructor(options: ActionOptions) { + if ( + options.context.type !== ActionContextType.Dao || + !options.context.dao.info.supportedFeatures[Feature.Approval] + ) { + throw new Error('Invalid context for setting up an approver') + } + + super(options, { + Icon: PersonRaisingHandEmoji, + label: options.t('title.setUpApprover'), + description: options.t('info.setUpApproverDescription'), + }) + } + + async encode({ + address: preProposeApprovalContract, + }: SetUpApproverData): Promise { + // Type-check. This is already checked in the constructor. + if ( + this.options.context.type !== ActionContextType.Dao || + this.options.chainContext.type !== ActionChainContextType.Supported + ) { + throw new Error('Invalid context for setting up an approver') + } + + if (!preProposeApprovalContract) { + throw new Error('No DAO selected.') + } + + const singleChoiceProposalModule = + this.options.context.dao.proposalModules.find( + (module) => + module instanceof SingleChoiceProposalModule || + module instanceof SecretSingleChoiceProposalModule + ) + if (!singleChoiceProposalModule) { + throw new Error('No single choice proposal module found') + } + + const config = await this.options.queryClient.fetchQuery< + SingleChoiceConfig | SecretSingleChoiceConfig + >( + // Type-cast since we know the module is either a single choice or + // secret single choice proposal module. + singleChoiceProposalModule.getConfigQuery() as any + ) + + const info: ModuleInstantiateInfo = { + admin: { core_module: {} }, + code_id: this.options.chainContext.config.codeIds.DaoProposalSingle, + label: `dao-proposal-single_approver_${Date.now()}`, + msg: encodeJsonToBase64({ + threshold: config.threshold, + allow_revoting: config.allow_revoting, + close_proposal_on_execution_failure: + 'close_proposal_on_execution_failure' in config + ? config.close_proposal_on_execution_failure + : true, + min_voting_period: + 'min_voting_period' in config ? config.min_voting_period : undefined, + max_voting_period: config.max_voting_period, + only_members_execute: config.only_members_execute, + veto: 'veto' in config ? config.veto : undefined, + pre_propose_info: { + module_may_propose: { + info: { + admin: { core_module: {} }, + code_id: + this.options.chainContext.config.codeIds.DaoPreProposeApprover, + label: `dao-pre-propose-approver_${Date.now()}`, + msg: encodeJsonToBase64({ + pre_propose_approval_contract: preProposeApprovalContract, + } as DaoPreProposeApproverInstantiateMsg), + funds: [], + }, + }, + }, + } as DaoProposalSingleInstantiateMsg), + funds: [], + } + + return makeExecuteSmartContractMessage({ + chainId: this.options.chain.chain_id, + contractAddress: this.options.address, + sender: this.options.address, + msg: { + update_proposal_modules: { + to_add: [info], + to_disable: [], + }, + }, + }) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + if ( + !objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + update_proposal_modules: { + to_add: [ + { + admin: {}, + code_id: {}, + label: {}, + msg: {}, + }, + ], + to_disable: [], + }, + }, + }, + }, + }) + ) { + return false + } + + const info = + decodedMessage.wasm.execute.msg.update_proposal_modules.to_add[0] + const parsedMsg = decodeJsonFromBase64(info.msg, true) + if ( + (!info.label.startsWith('dao-proposal-single_approver') && + // backwards compatibility + !info.label.endsWith(`${DaoProposalSingleAdapterId}_approver`)) || + !objectMatchesStructure(parsedMsg, { + pre_propose_info: { + module_may_propose: { + info: { + msg: {}, + }, + }, + }, + }) || + !parsedMsg.pre_propose_info.module_may_propose.info.label.includes( + 'approver' + ) + ) { + return false + } + + const parsedPreProposeMsg = decodeJsonFromBase64( + parsedMsg.pre_propose_info.module_may_propose.info.msg, + true + ) + return objectMatchesStructure(parsedPreProposeMsg, { + pre_propose_approval_contract: {}, + }) + } + + decode([{ decodedMessage }]: ProcessedMessage[]): SetUpApproverData { + const parsedPreProposeMsg = decodeJsonFromBase64( + decodeJsonFromBase64( + decodedMessage.wasm.execute.msg.update_proposal_modules.to_add[0].msg, + true + ).pre_propose_info.module_may_propose.info.msg, + true + ) + + return { + address: parsedPreProposeMsg.pre_propose_approval_contract, + } + } +} diff --git a/packages/stateful/actions/core/treasury/Spend/Component.stories.tsx b/packages/stateful/actions/core/actions/Spend/Component.stories.tsx similarity index 93% rename from packages/stateful/actions/core/treasury/Spend/Component.stories.tsx rename to packages/stateful/actions/core/actions/Spend/Component.stories.tsx index c6d111928..96ef861b7 100644 --- a/packages/stateful/actions/core/treasury/Spend/Component.stories.tsx +++ b/packages/stateful/actions/core/actions/Spend/Component.stories.tsx @@ -22,7 +22,6 @@ export default { from: '', to: '', amount: 1, - decimals: 6, cw20: false, denom: getNativeTokenForChainId(CHAIN_ID).denomOrAddress, }), @@ -49,7 +48,7 @@ Default.args = { data: [ { owner: { - type: AccountType.Native, + type: AccountType.Base, chainId: CHAIN_ID, address: 'first', }, @@ -58,7 +57,7 @@ Default.args = { }, { owner: { - type: AccountType.Native, + type: AccountType.Base, chainId: CHAIN_ID, address: 'first', }, @@ -79,7 +78,7 @@ Default.args = { }, { owner: { - type: AccountType.Native, + type: AccountType.Base, chainId: CHAIN_ID, address: 'second', }, @@ -105,7 +104,7 @@ Default.args = { ibcAmountOut: { loading: true, errored: false }, betterNonPfmIbcPath: { loading: true }, missingAccountChainIds: [], - nobleTariff: { loading: false, errored: false, data: undefined }, + nobleTariff: { loading: false, errored: false, data: null }, neutronTransferFee: { loading: false, errored: false, data: undefined }, proposalModuleMaxVotingPeriodInBlocks: false, AddressInput, diff --git a/packages/stateful/actions/core/treasury/Spend/Component.tsx b/packages/stateful/actions/core/actions/Spend/Component.tsx similarity index 97% rename from packages/stateful/actions/core/treasury/Spend/Component.tsx rename to packages/stateful/actions/core/actions/Spend/Component.tsx index 8607d6483..77e3bd86e 100644 --- a/packages/stateful/actions/core/treasury/Spend/Component.tsx +++ b/packages/stateful/actions/core/actions/Spend/Component.tsx @@ -22,6 +22,7 @@ import { StatusCard, TokenAmountDisplay, TokenInput, + useActionOptions, useDetectWrap, } from '@dao-dao/stateless' import { @@ -60,8 +61,6 @@ import { validateRequired, } from '@dao-dao/utils' -import { useActionOptions } from '../../../react' - export type SpendData = { fromChainId: string /* @@ -77,7 +76,6 @@ export type SpendData = { to: string amount: number denom: string - decimals: number /** * Whether or not `denom` is a CW20 token address. CW20 tokens cannot be sent * to a different chain. @@ -131,7 +129,7 @@ export type SpendOptions = { missingAccountChainIds?: string[] // If this spend is Noble USDC and leaves Noble at some point, these are the // fee settings. - nobleTariff: LoadingDataWithError + nobleTariff: LoadingDataWithError // If this spend incurs an IBC transfer fee on Neutron, show it. neutronTransferFee: LoadingDataWithError // Whether or not the proposal max voting period is in blocks. @@ -176,7 +174,6 @@ export const SpendComponent: ActionComponent = ({ const spendChainId = watch((fieldNamePrefix + 'fromChainId') as 'fromChainId') const spendAmount = watch((fieldNamePrefix + 'amount') as 'amount') const spendDenom = watch((fieldNamePrefix + 'denom') as 'denom') - const spendDecimals = watch((fieldNamePrefix + 'decimals') as 'decimals') const isCw20 = watch((fieldNamePrefix + 'cw20') as 'cw20') const from = watch((fieldNamePrefix + 'from') as 'from') const recipient = watch((fieldNamePrefix + 'to') as 'to') @@ -290,7 +287,7 @@ export const SpendComponent: ActionComponent = ({ : !selectedToken ? t('error.unknownDenom', { denom: spendDenom }) : spendAmount > balance - ? t('error.spendActionInsufficientWarning', { + ? t('error.insufficientFundsWarning', { amount: balance.toLocaleString(undefined, { maximumFractionDigits: decimals, }), @@ -344,7 +341,7 @@ export const SpendComponent: ActionComponent = ({ // For custom token, show unit if loaded successfully. unit: loadedCustomToken ? token.data.symbol : undefined, unitIconUrl: loadedCustomToken - ? token.data.imageUrl + ? token.data.imageUrl || undefined : undefined, unitClassName: '!text-text-primary', }} @@ -367,7 +364,6 @@ export const SpendComponent: ActionComponent = ({ // Custom token if (!token) { - setValue((fieldNamePrefix + 'decimals') as 'decimals', 0) return } @@ -395,10 +391,6 @@ export const SpendComponent: ActionComponent = ({ (fieldNamePrefix + 'denom') as 'denom', token.denomOrAddress ) - setValue( - (fieldNamePrefix + 'decimals') as 'decimals', - token.decimals - ) setValue( (fieldNamePrefix + 'cw20') as 'cw20', token.type === TokenType.Cw20 @@ -502,7 +494,7 @@ export const SpendComponent: ActionComponent = ({ = ({
- + ) : (
= ({ }, }) addAction({ - actionKey: ActionKey.ManageIcas, + actionKey: ActionKey.HideIca, data: { chainId, register: true, diff --git a/packages/stateful/actions/core/treasury/Spend/README.md b/packages/stateful/actions/core/actions/Spend/README.md similarity index 97% rename from packages/stateful/actions/core/treasury/Spend/README.md rename to packages/stateful/actions/core/actions/Spend/README.md index 23dc6ab3b..6a4d693ce 100644 --- a/packages/stateful/actions/core/treasury/Spend/README.md +++ b/packages/stateful/actions/core/actions/Spend/README.md @@ -21,9 +21,7 @@ guide](https://github.com/DA0-DA0/dao-dao-ui/wiki/Bulk-importing-actions). "to": "", "amount": , "denom": "", - "decimals": , "cw20": "", - "decimals": ", "units": "seconds" diff --git a/packages/stateful/actions/core/treasury/Spend/index.tsx b/packages/stateful/actions/core/actions/Spend/index.tsx similarity index 51% rename from packages/stateful/actions/core/treasury/Spend/index.tsx rename to packages/stateful/actions/core/actions/Spend/index.tsx index 25b8d1741..fc9a64cfc 100644 --- a/packages/stateful/actions/core/treasury/Spend/index.tsx +++ b/packages/stateful/actions/core/actions/Spend/index.tsx @@ -1,26 +1,30 @@ import { coin, coins } from '@cosmjs/amino' import { useQueryClient } from '@tanstack/react-query' -import { ComponentType, useCallback, useEffect, useState } from 'react' +import { ComponentType, useEffect, useState } from 'react' import { useFormContext } from 'react-hook-form' import { constSelector, useRecoilValue } from 'recoil' -import { tokenQueries } from '@dao-dao/state/query' +import { + neutronQueries, + nobleQueries, + skipQueries, + tokenQueries, +} from '@dao-dao/state/query' import { accountsSelector, - neutronIbcTransferFeeSelector, - nobleTariffTransferFeeSelector, - skipAllChainsPfmEnabledSelector, skipRouteMessageSelector, skipRouteSelector, } from '@dao-dao/state/recoil' import { + ActionBase, MoneyEmoji, - useCachedLoading, + useActionOptions, useCachedLoadingWithError, } from '@dao-dao/stateless' import { AccountType, ChainId, + Duration, DurationUnits, Entity, GenericTokenBalanceWithOwner, @@ -28,15 +32,16 @@ import { LoadingDataWithError, TokenType, UnifiedCosmosMsg, - UseDecodedCosmosMsg, + ValenceAccount, } from '@dao-dao/types' import { ActionComponentProps, ActionContextType, + ActionEncodeContext, ActionKey, - ActionMaker, - UseDefaults, - UseTransformToCosmos, + ActionMatch, + ActionOptions, + ProcessedMessage, } from '@dao-dao/types/actions' import { makeStargateMessage } from '@dao-dao/types/protobuf' import { MsgCommunityPoolSpend } from '@dao-dao/types/protobuf/codegen/cosmos/distribution/v1beta1/tx' @@ -47,10 +52,8 @@ import { convertDenomToMicroDenomStringWithDecimals, convertDurationWithUnitsToSeconds, convertMicroDenomToDenomWithDecimals, - decodeIcaExecuteMsg, - decodePolytoneExecuteMsg, + decodeMessage, getAccountAddress, - getChainAddressForActionOptions, getChainForChainId, getChainForChainName, getIbcTransferInfoBetweenChains, @@ -60,11 +63,12 @@ import { isDecodedStargateMsg, isValidBech32Address, makeBankMessage, + makeExecuteSmartContractMessage, makeWasmMessage, maybeGetChainForChainId, maybeGetNativeTokenForChainId, - maybeMakeIcaExecuteMessage, - maybeMakePolytoneExecuteMessage, + maybeMakeIcaExecuteMessages, + maybeMakePolytoneExecuteMessages, objectMatchesStructure, parseValidPfmMemo, transformBech32Address, @@ -72,84 +76,15 @@ import { import { AddressInput } from '../../../../components' import { useQueryLoadingDataWithError } from '../../../../hooks' -import { useWallet } from '../../../../hooks/useWallet' import { useProposalModuleAdapterCommonContextIfAvailable } from '../../../../proposal-module-adapter/react/context' import { entityQueries } from '../../../../queries/entity' import { useTokenBalances } from '../../../hooks/useTokenBalances' -import { useActionOptions } from '../../../react' import { SpendData, SpendComponent as StatelessSpendComponent, } from './Component' -const useDefaults: UseDefaults = () => { - const { - chain: { chain_id: chainId }, - address, - context, - } = useActionOptions() - const { address: walletAddress = '' } = useWallet() - - // Should always be defined if in a DAO proposal. Even for a DAO, it may not - // be defined if being authz executed or something similar. - const maxVotingPeriodSelector = - useProposalModuleAdapterCommonContextIfAvailable()?.common?.selectors - ?.maxVotingPeriod || - // If no selector, default to 0 time (likely in authz context). - constSelector({ time: 0 }) - const proposalModuleMaxVotingPeriod = useCachedLoadingWithError( - context.type === ActionContextType.Dao - ? maxVotingPeriodSelector - : context.type === ActionContextType.Wallet - ? // Wallets execute transactions right away, so there's no voting delay. - constSelector({ - time: 0, - }) - : context.type === ActionContextType.Gov - ? constSelector({ - // Seconds - time: context.params.votingPeriod - ? Number(context.params.votingPeriod.seconds) + - context.params.votingPeriod.nanos / 1e9 - : // If no voting period loaded, default to 30 days. - 30 * 24 * 60 * 60, - }) - : undefined - ) - - if (proposalModuleMaxVotingPeriod.loading) { - return - } else if (proposalModuleMaxVotingPeriod.errored) { - return proposalModuleMaxVotingPeriod.error - } - - const nativeToken = maybeGetNativeTokenForChainId(chainId) - - return { - fromChainId: chainId, - toChainId: chainId, - from: address, - to: walletAddress, - amount: 1, - denom: nativeToken?.denomOrAddress || '', - decimals: nativeToken?.decimals || 0, - cw20: nativeToken?.type === TokenType.Cw20, - ibcTimeout: - 'time' in proposalModuleMaxVotingPeriod.data - ? // 1 week if voting period is a time since we can append it after. - { - value: 1, - units: DurationUnits.Weeks, - } - : // 30 days if max voting period is in blocks since we can't append time and need to choose a conservative value. - { - value: 30, - units: DurationUnits.Days, - }, - } -} - -export const StatefulSpendComponent: ComponentType< +const StatefulSpendComponent: ComponentType< ActionComponentProps & { /** * Disallow changing the destination chain and address. This is useful if @@ -240,17 +175,7 @@ export const StatefulSpendComponent: ComponentType< return } - const decimals = getValues( - (props.fieldNamePrefix + 'decimals') as 'decimals' - ) const isCw20 = getValues((props.fieldNamePrefix + 'cw20') as 'cw20') - - if (decimals !== loadingToken.data.decimals) { - setValue( - (props.fieldNamePrefix + 'decimals') as 'decimals', - loadingToken.data.decimals - ) - } if (isCw20 !== (loadingToken.data.type === TokenType.Cw20)) { setValue( (props.fieldNamePrefix + 'cw20') as 'cw20', @@ -311,7 +236,7 @@ export const StatefulSpendComponent: ComponentType< // Only address is checked so the specific account type is not // a big deal. owner: { - type: AccountType.Native, + type: AccountType.Base, chainId: fromChainId, address: from, }, @@ -384,7 +309,7 @@ export const StatefulSpendComponent: ComponentType< accounts: accounts.data, chainId, types: [ - AccountType.Native, + AccountType.Base, AccountType.Polytone, AccountType.Ica, ], @@ -499,7 +424,7 @@ export const StatefulSpendComponent: ComponentType< } // Compute chain fees. - const nobleTariff = useCachedLoadingWithError( + const nobleTariff = useQueryLoadingDataWithError( ibcPath.loading || ibcPath.errored ? undefined : MAINNET && @@ -509,18 +434,18 @@ export const StatefulSpendComponent: ComponentType< // If Noble is one of the non-destination chains, meaning it will be // transferred out of Noble at some point. ibcPath.data.slice(0, -1).includes(ChainId.NobleMainnet) - ? nobleTariffTransferFeeSelector - : constSelector(undefined) + ? nobleQueries.ibcTransferFee() + : undefined ) - const neutronTransferFee = useCachedLoadingWithError( + const neutronTransferFee = useQueryLoadingDataWithError( ibcPath.loading || ibcPath.errored ? undefined : MAINNET && // If Neutron is one of the non-destination chains, meaning it will be // transferred out of Neutron at some point. ibcPath.data.slice(0, -1).includes(ChainId.NeutronMainnet) - ? neutronIbcTransferFeeSelector - : constSelector(undefined) + ? neutronQueries.ibcTransferFee(queryClient) + : undefined ) // Store skip route message once loaded successfully during creation. @@ -611,459 +536,519 @@ export const StatefulSpendComponent: ComponentType< ) } -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - const options = useActionOptions() - const defaults = useDefaults() - const queryClient = useQueryClient() +export class SpendAction extends ActionBase { + public readonly key: ActionKey = ActionKey.Spend + public Component = StatefulSpendComponent + + constructor(options: ActionOptions) { + super(options, { + Icon: MoneyEmoji, + label: options.t('title.spend'), + description: options.t('info.spendActionDescription', { + context: options.context.type, + }), + // Some actions use Spend under the hood, like Fund and Withdraw + // Rebalancer. + matchPriority: -1, + }) - let chainId = options.chain.chain_id - let from = options.address + const nativeToken = maybeGetNativeTokenForChainId( + this.options.chain.chain_id + ) - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - msg = decodedPolytone.msg - chainId = decodedPolytone.chainId - from = getChainAddressForActionOptions(options, chainId) || '' - } else { - const decodedIca = decodeIcaExecuteMsg(chainId, msg) - if (decodedIca.match) { - chainId = decodedIca.chainId - msg = decodedIca.msgWithSender?.msg || {} - from = decodedIca.msgWithSender?.sender || '' + this.defaults = { + fromChainId: this.options.chain.chain_id, + toChainId: this.options.chain.chain_id, + from: this.options.address, + to: '', + amount: 1, + denom: nativeToken?.denomOrAddress || '', + cw20: nativeToken?.type === TokenType.Cw20, + ibcTimeout: { + value: 1, + units: DurationUnits.Weeks, + }, } } - const isNative = - objectMatchesStructure(msg, { + async encode( + { + fromChainId, + toChainId, + from, + to, + amount: _amount, + denom, + cw20, + ibcTimeout, + useDirectIbcPath, + _skipIbcTransferMsg, + }: SpendData, + encodeContext: ActionEncodeContext + ): Promise { + const { decimals } = await this.options.queryClient.fetchQuery( + tokenQueries.info(this.options.queryClient, { + chainId: fromChainId, + denomOrAddress: denom, + type: cw20 ? TokenType.Cw20 : TokenType.Native, + }) + ) + const amount = convertDenomToMicroDenomStringWithDecimals(_amount, decimals) + + // Gov module community pool spend. + if (this.options.context.type === ActionContextType.Gov) { + return makeStargateMessage({ + stargate: { + typeUrl: MsgCommunityPoolSpend.typeUrl, + value: MsgCommunityPoolSpend.fromPartial({ + authority: this.options.address, + recipient: to, + amount: coins(amount, denom), + }), + }, + }) + } + + let spendAccount = this.options.context.accounts.find( + (a) => a.chainId === fromChainId && a.address === from + ) + // Should never happen. + if (!spendAccount) { + throw new Error(this.options.t('error.failedToFindSpendingAccount')) + } + + let msg: UnifiedCosmosMsg | undefined + // IBC transfer of native token. + if (!cw20 && toChainId !== fromChainId) { + // Load voting period so we can add the IBC timeout to it. + const maxVotingPeriod: Duration = + // If in a DAO, load voting period from proposal module. + (encodeContext.type === ActionContextType.Dao && + (await encodeContext.proposalModule.getMaxVotingPeriod())) || + (encodeContext.type === ActionContextType.Gov + ? { + // Seconds + time: encodeContext.params.votingPeriod + ? Number(encodeContext.params.votingPeriod.seconds) + + encodeContext.params.votingPeriod.nanos / 1e9 + : // If no voting period loaded, default to 30 days. + 30 * 24 * 60 * 60, + } + : // If not in DAO with a proposal module, and not in gov, default to 0. This is probably a wallet, which executes transactions immediately. + { time: 0 }) + + // Default to conservative 30 days if no IBC timeout is set for some + // reason. This should never happen. + const timeoutSeconds = ibcTimeout + ? convertDurationWithUnitsToSeconds(ibcTimeout) + : 30 * 24 * 60 * 60 + // Convert seconds to nanoseconds. + const timeoutTimestamp = BigInt( + Date.now() * 1e6 + + // Add timeout to voting period if it's a time duration. + ((!('time' in maxVotingPeriod) ? 0 : maxVotingPeriod.time) + + timeoutSeconds) * + 1e9 + ) + + // If no Skip IBC msg or it errored or disabled, use single-hop IBC + // transfer. + if ( + useDirectIbcPath || + !_skipIbcTransferMsg || + _skipIbcTransferMsg.loading || + _skipIbcTransferMsg.errored + ) { + const { sourceChannel } = getIbcTransferInfoBetweenChains( + fromChainId, + toChainId + ) + msg = makeStargateMessage({ + stargate: { + typeUrl: + fromChainId === ChainId.NeutronMainnet || + fromChainId === ChainId.NeutronTestnet + ? NeutronMsgTransfer.typeUrl + : MsgTransfer.typeUrl, + value: { + sourcePort: 'transfer', + sourceChannel, + token: coin(amount, denom), + sender: from, + receiver: to, + timeoutTimestamp, + memo: '', + // Add Neutron IBC transfer fee if sending from Neutron. + ...((fromChainId === ChainId.NeutronMainnet || + fromChainId === ChainId.NeutronTestnet) && { + fee: ( + await this.options.queryClient.fetchQuery( + neutronQueries.ibcTransferFee(this.options.queryClient) + ) + )?.fee, + }), + } as NeutronMsgTransfer, + }, + }) + } else { + if ( + _skipIbcTransferMsg.data.msg_type_url !== MsgTransfer.typeUrl && + _skipIbcTransferMsg.data.msg_type_url !== NeutronMsgTransfer.typeUrl + ) { + throw new Error( + `Unexpected Skip transfer message type: ${_skipIbcTransferMsg.data.msg_type_url}` + ) + } + + const skipTransferMsgValue = JSON.parse(_skipIbcTransferMsg.data.msg) + msg = makeStargateMessage({ + stargate: { + typeUrl: + fromChainId === ChainId.NeutronMainnet || + fromChainId === ChainId.NeutronTestnet + ? NeutronMsgTransfer.typeUrl + : MsgTransfer.typeUrl, + value: { + ...(fromChainId === ChainId.NeutronMainnet || + fromChainId === ChainId.NeutronTestnet + ? NeutronMsgTransfer + : MsgTransfer + ).fromAmino({ + ...skipTransferMsgValue, + // Replace all forwarding timeouts with our own. If no memo, + // use empty string. This will be undefined if PFM is not + // used and it's only a single hop. + memo: + (typeof skipTransferMsgValue.memo === 'string' && + skipTransferMsgValue.memo.replace( + /"timeout":\d+/g, + `"timeout":${timeoutTimestamp.toString()}` + )) || + '', + timeout_timestamp: timeoutTimestamp, + timeout_height: undefined, + }), + }, + }, + }) + } + } else if (!cw20) { + msg = { + bank: makeBankMessage(amount, to, denom), + } + } else { + msg = makeWasmMessage({ + wasm: { + execute: { + contract_addr: denom, + funds: [], + msg: { + transfer: { + recipient: to, + amount, + }, + }, + }, + }, + }) + } + + // If spending from Valence account, perform admin execute. + if (spendAccount.type === AccountType.Valence) { + // Only the Valence account admin can withdraw funds. + const valenceAdmin = this.options.context.accounts.find( + (a) => + spendAccount && + a.chainId === spendAccount.chainId && + a.address === (spendAccount as ValenceAccount).config.admin + ) + if (!valenceAdmin) { + throw new Error('Valence account owner not found') + } + + // Wrap message in Valence admin execute. + msg = makeExecuteSmartContractMessage({ + chainId: spendAccount.chainId, + contractAddress: spendAccount.address, + sender: valenceAdmin.address, + msg: { + execute_by_admin: { + msgs: [msg], + }, + }, + }) + // Change spend account to Valence admin. + spendAccount = valenceAdmin + } + + return spendAccount.type === AccountType.Ica + ? maybeMakeIcaExecuteMessages( + this.options.chain.chain_id, + fromChainId, + this.options.address, + spendAccount.address, + msg + ) + : maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + fromChainId, + msg + ) + } + + async match([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): Promise { + // Unwrap Valence admin execute message. + if ( + objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + execute_by_admin: { + msgs: [{}], + }, + }, + }, + }, + }) + ) { + decodedMessage = decodeMessage( + decodedMessage.wasm.execute.msg.execute_by_admin.msgs[0] + ) + } + + const isNative = objectMatchesStructure(decodedMessage, { bank: { send: { - amount: {}, + amount: [ + { + amount: {}, + denom: {}, + }, + ], to_address: {}, }, }, - }) && - msg.bank.send.amount.length === 1 && - objectMatchesStructure(msg.bank.send.amount[0], { - amount: {}, - denom: {}, }) - const isCw20 = objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - msg: { - transfer: { - recipient: {}, - amount: {}, + const isCw20 = objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + msg: { + transfer: { + recipient: {}, + amount: {}, + }, }, }, }, - }, - }) + }) - const isIbcTransfer = - isDecodedStargateMsg(msg) && - (msg.stargate.typeUrl === MsgTransfer.typeUrl || - msg.stargate.typeUrl === NeutronMsgTransfer.typeUrl) && - objectMatchesStructure(msg.stargate.value, { - sourcePort: {}, - sourceChannel: {}, - token: {}, - sender: {}, - receiver: {}, - }) && - msg.stargate.value.sourcePort === 'transfer' + const isIbcTransfer = + isDecodedStargateMsg(decodedMessage, [MsgTransfer, NeutronMsgTransfer], { + sourcePort: {}, + sourceChannel: {}, + token: {}, + sender: {}, + receiver: {}, + }) && decodedMessage.stargate.value.sourcePort === 'transfer' - const token = useQueryLoadingDataWithError( - isNative || isCw20 || isIbcTransfer - ? tokenQueries.info(queryClient, { - chainId, - type: isNative || isIbcTransfer ? TokenType.Native : TokenType.Cw20, - denomOrAddress: isIbcTransfer - ? msg.stargate.value.token.denom - : isNative - ? msg.bank.send.amount[0].denom - : msg.wasm.execute.contract_addr, - }) - : undefined - ) + // Try to parse packet-forward-middleware memo. + const pfmMemo = + isIbcTransfer && decodedMessage.stargate.value.memo + ? parseValidPfmMemo(decodedMessage.stargate.value.memo) + : undefined - // Try to parse packet-forward-middleware memo. - const pfmMemo = - isIbcTransfer && msg.stargate.value.memo - ? parseValidPfmMemo(msg.stargate.value.memo) - : undefined + // If valid PFM memo, validate that all chains (except the receiver) have + // enabled PFM. + const pfmChainPath = + pfmMemo && + getPfmChainPathFromMemo( + chainId, + decodedMessage.stargate.value.sourceChannel, + pfmMemo + ) - // If valid PFM memo, validate that all chains (except the receiver) have - // enabled PFM. - const pfmChainPath = - pfmMemo && - getPfmChainPathFromMemo(chainId, msg.stargate.value.sourceChannel, pfmMemo) - const allChainsExceptReceiverPfmEnabled = useCachedLoadingWithError( - pfmChainPath?.length - ? skipAllChainsPfmEnabledSelector(pfmChainPath.slice(0, -1)) - : undefined - ) + const hasPfmChainPath = pfmChainPath && pfmChainPath.length > 1 + const allChainsExceptReceiverPfmEnabled = hasPfmChainPath + ? ( + await Promise.all( + pfmChainPath.slice(0, -1).map((chainId) => + this.options.queryClient.fetchQuery( + skipQueries.chainPfmEnabled(this.options.queryClient, { + chainId, + }) + ) + ) + ) + ).every(Boolean) + : false - if ( - // If somehow failed to load from address, don't match. - !from || - token.loading || - token.errored || - // If this is a valid PFM message, ensure all chains have PFM enabled or - // else this is invalid and may be unsafe to display. If we can't properly - // determine where it ended up, we shouldn't show the action to avoid - // misleading information. - (!!pfmChainPath?.length && - (allChainsExceptReceiverPfmEnabled.loading || - allChainsExceptReceiverPfmEnabled.errored || - !allChainsExceptReceiverPfmEnabled.data)) - ) { - return { match: false } + return ( + isNative || + isCw20 || + // If chains don't have PFM enabled, this may be a malicious spend. + (isIbcTransfer && (!hasPfmChainPath || allChainsExceptReceiverPfmEnabled)) + ) } - if (isIbcTransfer) { - // Get destination chain of first hop. If no PFM, this is the only hop. - const { destinationChain } = getIbcTransferInfoFromChannel( - chainId, - msg.stargate.value.sourceChannel + async decode([ + { + decodedMessage, + account: { chainId, address: from }, + }, + ]: ProcessedMessage[]): Promise { + // Unwrap Valence admin execute message. + if ( + objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + execute_by_admin: { + msgs: [{}], + }, + }, + }, + }, + }) + ) { + from = decodedMessage.wasm.execute.contract_addr + decodedMessage = decodeMessage( + decodedMessage.wasm.execute.msg.execute_by_admin.msgs[0] + ) + } + + const isNative = objectMatchesStructure(decodedMessage, { + bank: { + send: { + amount: [ + { + amount: {}, + denom: {}, + }, + ], + to_address: {}, + }, + }, + }) + + const isIbcTransfer = + isDecodedStargateMsg(decodedMessage, [MsgTransfer, NeutronMsgTransfer], { + sourcePort: {}, + sourceChannel: {}, + token: {}, + sender: {}, + receiver: {}, + }) && decodedMessage.stargate.value.sourcePort === 'transfer' + + const token = await this.options.queryClient.fetchQuery( + tokenQueries.info(this.options.queryClient, { + chainId, + type: isNative || isIbcTransfer ? TokenType.Native : TokenType.Cw20, + denomOrAddress: isIbcTransfer + ? decodedMessage.stargate.value.token.denom + : isNative + ? decodedMessage.bank.send.amount[0].denom + : // isCw20 + decodedMessage.wasm.execute.contract_addr, + }) ) - const toChainId = - pfmMemo && pfmChainPath?.length + // Try to parse packet-forward-middleware memo. + const pfmMemo = + isIbcTransfer && decodedMessage.stargate.value.memo + ? parseValidPfmMemo(decodedMessage.stargate.value.memo) + : undefined + const pfmChainPath = + pfmMemo && + getPfmChainPathFromMemo( + chainId, + decodedMessage.stargate.value.sourceChannel, + pfmMemo + ) + + if (isIbcTransfer) { + const toChainId = pfmChainPath ? pfmChainPath[pfmChainPath.length - 1] - : getChainForChainName(destinationChain.chain_name).chain_id - const to = - pfmMemo && pfmChainPath?.length + : getChainForChainName( + // Get destination chain of hop. + getIbcTransferInfoFromChannel( + chainId, + decodedMessage.stargate.value.sourceChannel + ).destinationChain.chain_name + ).chain_id + const to = pfmChainPath ? getPfmFinalReceiverFromMemo(pfmMemo) - : msg.stargate.value.receiver - - return { - match: true, - data: { - ...(defaults instanceof Error ? {} : defaults), + : decodedMessage.stargate.value.receiver + return { fromChainId: chainId, toChainId, from, to, amount: convertMicroDenomToDenomWithDecimals( - msg.stargate.value.token.amount, - token.data.decimals + decodedMessage.stargate.value.token.amount, + token.decimals ), - denom: token.data.denomOrAddress, - decimals: token.data.decimals, + denom: token.denomOrAddress, // Should always be false. - cw20: token.data.type === TokenType.Cw20, + cw20: token.type === TokenType.Cw20, // Nanoseconds to milliseconds. _absoluteIbcTimeout: Number( - msg.stargate.value.timeoutTimestamp / BigInt(1e6) + decodedMessage.stargate.value.timeoutTimestamp / BigInt(1e6) ), _ibcData: { - sourceChannel: msg.stargate.value.sourceChannel, + sourceChannel: decodedMessage.stargate.value.sourceChannel, pfmMemo: pfmMemo && JSON.stringify(pfmMemo), }, - }, - } - } else if (token.data.type === TokenType.Native) { - return { - match: true, - data: { - ...(defaults instanceof Error ? {} : defaults), - + } + } else if (token.type === TokenType.Native) { + return { fromChainId: chainId, toChainId: chainId, from, - to: msg.bank.send.to_address, + to: decodedMessage.bank.send.to_address, amount: convertMicroDenomToDenomWithDecimals( - msg.bank.send.amount[0].amount, - token.data.decimals + decodedMessage.bank.send.amount[0].amount, + token.decimals ), - denom: token.data.denomOrAddress, - decimals: token.data.decimals, + denom: token.denomOrAddress, cw20: false, - }, - } - } else if (token.data.type === TokenType.Cw20) { - return { - match: true, - data: { - ...(defaults instanceof Error ? {} : defaults), - + } + } else if (token.type === TokenType.Cw20) { + return { fromChainId: chainId, toChainId: chainId, from, - to: msg.wasm.execute.msg.transfer.recipient, + to: decodedMessage.wasm.execute.msg.transfer.recipient, amount: convertMicroDenomToDenomWithDecimals( - msg.wasm.execute.msg.transfer.amount, - token.data.decimals + decodedMessage.wasm.execute.msg.transfer.amount, + token.decimals ), - denom: msg.wasm.execute.contract_addr, - decimals: token.data.decimals, + denom: decodedMessage.wasm.execute.contract_addr, cw20: true, - }, + } } - } - - return { match: false } -} - -export const makeSpendAction: ActionMaker< - SpendData, - { - /** - * Whether or not to restrict the token options to Valence accounts. - * Defaults to false. - */ - fromValence?: boolean - } -> = ({ t, context }) => { - const useTransformToCosmos: UseTransformToCosmos = () => { - const options = useActionOptions() - - const neutronTransferFee = useCachedLoading( - neutronIbcTransferFeeSelector, - undefined - ) - - // Should always be defined if in a DAO proposal. Even for a DAO, it may not - // be defined if being authz executed or something similar. - const maxVotingPeriodSelector = - useProposalModuleAdapterCommonContextIfAvailable()?.common?.selectors - ?.maxVotingPeriod || - // If no selector, default to 0 time (likely in authz context). - constSelector({ time: 0 }) - const proposalModuleMaxVotingPeriod = useCachedLoadingWithError( - options.context.type === ActionContextType.Dao - ? maxVotingPeriodSelector - : options.context.type === ActionContextType.Wallet - ? // Wallets execute transactions right away, so there's no voting delay. - constSelector({ - time: 0, - }) - : options.context.type === ActionContextType.Gov - ? constSelector({ - // Seconds - time: options.context.params.votingPeriod - ? Number(options.context.params.votingPeriod.seconds) + - options.context.params.votingPeriod.nanos / 1e9 - : // If no voting period loaded, default to 30 days. - 30 * 24 * 60 * 60, - }) - : undefined - ) - - return useCallback( - ({ - fromChainId, - toChainId, - from, - to, - amount: _amount, - denom, - decimals, - cw20, - ibcTimeout, - useDirectIbcPath, - _skipIbcTransferMsg, - }: SpendData) => { - const amount = convertDenomToMicroDenomStringWithDecimals( - _amount, - decimals - ) - - const spendAccount = context.accounts.find( - (a) => a.chainId === fromChainId && a.address === from - ) - // Should never happen. - if (!spendAccount) { - throw new Error(t('error.failedToFindSpendingAccount')) - } - - // Gov module community pool spend. - if (options.context.type === ActionContextType.Gov) { - return makeStargateMessage({ - stargate: { - typeUrl: MsgCommunityPoolSpend.typeUrl, - value: { - authority: options.address, - recipient: to, - amount: coins(amount, denom), - } as MsgCommunityPoolSpend, - }, - }) - } - - let msg: UnifiedCosmosMsg | undefined - // IBC transfer of native token. - if (!cw20 && toChainId !== fromChainId) { - // Require that this loads before using IBC. - if ( - proposalModuleMaxVotingPeriod.loading || - proposalModuleMaxVotingPeriod.errored - ) { - throw new Error('Failed to load proposal module max voting period') - } - - // Default to conservative 30 days if no IBC timeout is set for some - // reason. This should never happen. - const timeoutSeconds = ibcTimeout - ? convertDurationWithUnitsToSeconds(ibcTimeout) - : 30 * 24 * 60 * 60 - // Convert seconds to nanoseconds. - const timeoutTimestamp = BigInt( - Date.now() * 1e6 + - // Add timeout to voting period if it's a time duration. - ((!('time' in proposalModuleMaxVotingPeriod.data) - ? 0 - : proposalModuleMaxVotingPeriod.data.time) + - timeoutSeconds) * - 1e9 - ) - - // If no Skip IBC msg or it errored or disabled, use single-hop IBC - // transfer. - if ( - useDirectIbcPath || - !_skipIbcTransferMsg || - _skipIbcTransferMsg.loading || - _skipIbcTransferMsg.errored - ) { - const { sourceChannel } = getIbcTransferInfoBetweenChains( - fromChainId, - toChainId - ) - msg = makeStargateMessage({ - stargate: { - typeUrl: - fromChainId === ChainId.NeutronMainnet || - fromChainId === ChainId.NeutronTestnet - ? NeutronMsgTransfer.typeUrl - : MsgTransfer.typeUrl, - value: { - sourcePort: 'transfer', - sourceChannel, - token: coin(amount, denom), - sender: from, - receiver: to, - timeoutTimestamp, - memo: '', - // Add Neutron IBC transfer fee if sending from Neutron. - ...((fromChainId === ChainId.NeutronMainnet || - fromChainId === ChainId.NeutronTestnet) && { - fee: neutronTransferFee.loading - ? undefined - : neutronTransferFee.data?.fee, - }), - } as NeutronMsgTransfer, - }, - }) - } else { - if ( - _skipIbcTransferMsg.data.msg_type_url !== MsgTransfer.typeUrl && - _skipIbcTransferMsg.data.msg_type_url !== - NeutronMsgTransfer.typeUrl - ) { - throw new Error( - `Unexpected Skip transfer message type: ${_skipIbcTransferMsg.data.msg_type_url}` - ) - } - const skipTransferMsgValue = JSON.parse( - _skipIbcTransferMsg.data.msg - ) - msg = makeStargateMessage({ - stargate: { - typeUrl: - fromChainId === ChainId.NeutronMainnet || - fromChainId === ChainId.NeutronTestnet - ? NeutronMsgTransfer.typeUrl - : MsgTransfer.typeUrl, - value: { - ...(fromChainId === ChainId.NeutronMainnet || - fromChainId === ChainId.NeutronTestnet - ? NeutronMsgTransfer - : MsgTransfer - ).fromAmino({ - ...skipTransferMsgValue, - // Replace all forwarding timeouts with our own. If no memo, - // use empty string. This will be undefined if PFM is not - // used and it's only a single hop. - memo: - (typeof skipTransferMsgValue.memo === 'string' && - skipTransferMsgValue.memo.replace( - /"timeout":\d+/g, - `"timeout":${timeoutTimestamp.toString()}` - )) || - '', - timeout_timestamp: timeoutTimestamp, - timeout_height: undefined, - }), - // Add Neutron IBC transfer fee if sending from Neutron. - ...((fromChainId === ChainId.NeutronMainnet || - fromChainId === ChainId.NeutronTestnet) && { - fee: neutronTransferFee.loading - ? undefined - : neutronTransferFee.data?.fee, - }), - }, - }, - }) - } - } else if (!cw20) { - msg = { - bank: makeBankMessage(amount, to, denom), - } - } else { - msg = makeWasmMessage({ - wasm: { - execute: { - contract_addr: denom, - funds: [], - msg: { - transfer: { - recipient: to, - amount, - }, - }, - }, - }, - }) - } - - return spendAccount.type === AccountType.Ica - ? maybeMakeIcaExecuteMessage( - options.chain.chain_id, - fromChainId, - options.address, - spendAccount.address, - msg - ) - : maybeMakePolytoneExecuteMessage( - options.chain.chain_id, - fromChainId, - msg - ) - }, - [options, neutronTransferFee, proposalModuleMaxVotingPeriod] - ) + throw new Error('Failed to decode spend message') } - return { - key: ActionKey.Spend, - Icon: MoneyEmoji, - label: t('title.spend'), - description: t('info.spendActionDescription', { - context: context.type, - }), - Component: StatefulSpendComponent, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, + transformImportData(data: any): SpendData { + return { + ...data, + // Ensure amount is a number. + amount: Number(data.amount), + } } } diff --git a/packages/stateful/actions/core/actions/TEMPLATE.md b/packages/stateful/actions/core/actions/TEMPLATE.md new file mode 100644 index 000000000..b72ebc8a8 --- /dev/null +++ b/packages/stateful/actions/core/actions/TEMPLATE.md @@ -0,0 +1,87 @@ +# Action template + +Here is a template to use when making a new action. + +```ts +import { ActionBase, Emoji } from '@dao-dao/stateless' +import { UnifiedCosmosMsg } from '@dao-dao/types' +import { + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { + makeExecuteSmartContractMessage, + maybeMakePolytoneExecuteMessages, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { Component, TemplateData } from './Component' + +export class TemplateAction extends ActionBase { + public readonly key = ActionKey.Template + public readonly Component = Component + + constructor(options: ActionOptions) { + super(options, { + Icon: Emoji, + label: options.t('title.label'), + description: options.t('info.description'), + }) + + this.defaults = { + chainId: options.chain.chain_id, + address: '', + field: '', + } + } + + encode({ chainId, address, field }: TemplateData): UnifiedCosmosMsg[] { + return maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + chainId, + makeExecuteSmartContractMessage({ + chainId, + sender: getChainAddressForActionOptions(this.options, chainId) || '', + contractAddress: address, + msg: { + do_something: { + field, + }, + }, + }) + ) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + do_something: { + field: {}, + }, + }, + }, + }, + }) + } + + decode([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): TemplateData { + return { + chainId, + address: decodedMessage.wasm.execute.contract_addr, + field: decodedMessage.wasm.execute.msg.do_something.field, + } + } +} +``` diff --git a/packages/stateful/actions/core/nfts/TransferNft/Component.stories.tsx b/packages/stateful/actions/core/actions/TransferNft/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/nfts/TransferNft/Component.stories.tsx rename to packages/stateful/actions/core/actions/TransferNft/Component.stories.tsx diff --git a/packages/stateful/actions/core/nfts/TransferNft/Component.tsx b/packages/stateful/actions/core/actions/TransferNft/Component.tsx similarity index 100% rename from packages/stateful/actions/core/nfts/TransferNft/Component.tsx rename to packages/stateful/actions/core/actions/TransferNft/Component.tsx diff --git a/packages/stateful/actions/core/nfts/TransferNft/README.md b/packages/stateful/actions/core/actions/TransferNft/README.md similarity index 100% rename from packages/stateful/actions/core/nfts/TransferNft/README.md rename to packages/stateful/actions/core/actions/TransferNft/README.md diff --git a/packages/stateful/actions/core/actions/TransferNft/index.tsx b/packages/stateful/actions/core/actions/TransferNft/index.tsx new file mode 100644 index 000000000..3c687b45e --- /dev/null +++ b/packages/stateful/actions/core/actions/TransferNft/index.tsx @@ -0,0 +1,235 @@ +import JSON5 from 'json5' +import { useFormContext } from 'react-hook-form' +import { constSelector } from 'recoil' + +import { + lazyNftCardInfosForDaoSelector, + nftCardInfoSelector, + walletLazyNftCardInfosSelector, +} from '@dao-dao/state/recoil' +import { + ActionBase, + BoxEmoji, + useActionOptions, + useCachedLoadingWithError, +} from '@dao-dao/stateless' +import { + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + LazyNftCardInfo, + LoadingDataWithError, + ProcessedMessage, + UnifiedCosmosMsg, +} from '@dao-dao/types' +import { + combineLoadingDataWithErrors, + decodeJsonFromBase64, + encodeJsonToBase64, + getChainAddressForActionOptions, + makeExecuteSmartContractMessage, + maybeMakePolytoneExecuteMessages, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { AddressInput, NftSelectionModal } from '../../../../components' +import { useCw721CommonGovernanceTokenInfoIfExists } from '../../../../voting-module-adapter' +import { TransferNftComponent, TransferNftData } from './Component' + +const Component: ActionComponent = (props) => { + const { + context, + address, + chain: { chain_id: currentChainId }, + } = useActionOptions() + const { watch } = useFormContext() + const { denomOrAddress: governanceCollectionAddress } = + useCw721CommonGovernanceTokenInfoIfExists() ?? {} + + const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') + const tokenId = watch((props.fieldNamePrefix + 'tokenId') as 'tokenId') + const collection = watch( + (props.fieldNamePrefix + 'collection') as 'collection' + ) + + const options = useCachedLoadingWithError( + props.isCreating + ? context.type === ActionContextType.Wallet + ? walletLazyNftCardInfosSelector({ + walletAddress: address, + chainId: currentChainId, + }) + : lazyNftCardInfosForDaoSelector({ + chainId: currentChainId, + coreAddress: address, + governanceCollectionAddress, + }) + : undefined + ) + const nftInfo = useCachedLoadingWithError( + chainId && collection && tokenId + ? nftCardInfoSelector({ chainId, collection, tokenId }) + : constSelector(undefined) + ) + + const allChainOptions = + options.loading || options.errored + ? options + : combineLoadingDataWithErrors( + ...Object.values(options.data).filter( + (data): data is LoadingDataWithError => !!data + ) + ) + + return ( + + ) +} + +export class TransferNftAction extends ActionBase { + public readonly key = ActionKey.TransferNft + public readonly Component = Component + + constructor(options: ActionOptions) { + super(options, { + Icon: BoxEmoji, + label: options.t('title.transferNft'), + description: options.t('info.transferNftDescription', { + context: options.context.type, + }), + }) + + this.defaults = { + chainId: options.chain.chain_id, + collection: '', + tokenId: '', + recipient: '', + + executeSmartContract: false, + smartContractMsg: '{}', + } + } + + encode({ + chainId, + collection, + tokenId, + recipient, + executeSmartContract, + smartContractMsg, + }: TransferNftData): UnifiedCosmosMsg[] { + const sender = getChainAddressForActionOptions(this.options, chainId) + if (!sender) { + throw new Error('No sender found for chain.') + } + + return maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + chainId, + makeExecuteSmartContractMessage({ + chainId, + sender, + contractAddress: collection, + msg: executeSmartContract + ? { + send_nft: { + contract: recipient, + msg: encodeJsonToBase64(JSON5.parse(smartContractMsg)), + token_id: tokenId, + }, + } + : { + transfer_nft: { + recipient, + token_id: tokenId, + }, + }, + }) + ) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return ( + objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + transfer_nft: { + recipient: {}, + token_id: {}, + }, + }, + }, + }, + }) || + objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + send_nft: { + contract: {}, + msg: {}, + token_id: {}, + }, + }, + }, + }, + }) + ) + } + + decode([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): TransferNftData { + return objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + msg: { + transfer_nft: {}, + }, + }, + }, + }) + ? { + chainId, + collection: decodedMessage.wasm.execute.contract_addr, + tokenId: decodedMessage.wasm.execute.msg.transfer_nft.token_id, + recipient: decodedMessage.wasm.execute.msg.transfer_nft.recipient, + executeSmartContract: false, + smartContractMsg: '{}', + } + : // send_nft + { + chainId, + collection: decodedMessage.wasm.execute.contract_addr, + tokenId: decodedMessage.wasm.execute.msg.send_nft.token_id, + recipient: decodedMessage.wasm.execute.msg.send_nft.contract, + executeSmartContract: true, + smartContractMsg: JSON.stringify( + decodeJsonFromBase64( + decodedMessage.wasm.execute.msg.send_nft.msg, + true + ), + null, + 2 + ), + } + } +} diff --git a/packages/stateful/actions/core/smart_contracting/UpdateAdmin/Component.stories.tsx b/packages/stateful/actions/core/actions/UpdateAdmin/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/smart_contracting/UpdateAdmin/Component.stories.tsx rename to packages/stateful/actions/core/actions/UpdateAdmin/Component.stories.tsx diff --git a/packages/stateful/actions/core/smart_contracting/UpdateAdmin/Component.tsx b/packages/stateful/actions/core/actions/UpdateAdmin/Component.tsx similarity index 97% rename from packages/stateful/actions/core/smart_contracting/UpdateAdmin/Component.tsx rename to packages/stateful/actions/core/actions/UpdateAdmin/Component.tsx index 387c5d54b..4e9f6e5f0 100644 --- a/packages/stateful/actions/core/smart_contracting/UpdateAdmin/Component.tsx +++ b/packages/stateful/actions/core/actions/UpdateAdmin/Component.tsx @@ -6,13 +6,12 @@ import { InputErrorMessage, InputLabel, StatusCard, + useActionOptions, useChain, } from '@dao-dao/stateless' import { ActionComponent } from '@dao-dao/types/actions' import { makeValidateAddress, validateRequired } from '@dao-dao/utils' -import { useActionOptions } from '../../../react/context' - export interface UpdateAdminOptions { contractAdmin: string | undefined } diff --git a/packages/stateful/actions/core/smart_contracting/UpdateAdmin/README.md b/packages/stateful/actions/core/actions/UpdateAdmin/README.md similarity index 100% rename from packages/stateful/actions/core/smart_contracting/UpdateAdmin/README.md rename to packages/stateful/actions/core/actions/UpdateAdmin/README.md diff --git a/packages/stateful/actions/core/actions/UpdateAdmin/index.tsx b/packages/stateful/actions/core/actions/UpdateAdmin/index.tsx new file mode 100644 index 000000000..0553b6080 --- /dev/null +++ b/packages/stateful/actions/core/actions/UpdateAdmin/index.tsx @@ -0,0 +1,160 @@ +import { useFormContext } from 'react-hook-form' +import { constSelector, useRecoilValueLoadable } from 'recoil' + +import { contractAdminSelector } from '@dao-dao/state' +import { + ActionBase, + ChainProvider, + DaoSupportedChainPickerInput, + MushroomEmoji, + useActionOptions, +} from '@dao-dao/stateless' +import { UnifiedCosmosMsg, makeStargateMessage } from '@dao-dao/types' +import { + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { MsgUpdateAdmin as SecretMsgUpdateAdmin } from '@dao-dao/types/protobuf/codegen/secret/compute/v1beta1/msg' +import { + getChainAddressForActionOptions, + getChainForChainId, + isDecodedStargateMsg, + isSecretNetwork, + isValidBech32Address, + maybeMakePolytoneExecuteMessages, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { UpdateAdminComponent as StatelessUpdateAdminComponent } from './Component' + +export type UpdateAdminData = { + chainId: string + contract: string + newAdmin: string +} + +const Component: ActionComponent = (props) => { + const { context } = useActionOptions() + const { watch } = useFormContext() + + const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') + const { bech32_prefix: bech32Prefix } = getChainForChainId(chainId) + + const contract = watch((props.fieldNamePrefix + 'contract') as 'contract') + + const admin = useRecoilValueLoadable( + contract && isValidBech32Address(contract, bech32Prefix) + ? contractAdminSelector({ + contractAddress: contract, + chainId, + }) + : constSelector(undefined) + ) + + return ( + <> + {context.type === ActionContextType.Dao && ( + + )} + + + + + + ) +} + +export class UpdateAdminAction extends ActionBase { + public readonly key = ActionKey.UpdateAdmin + public readonly Component = Component + + constructor(options: ActionOptions) { + super(options, { + Icon: MushroomEmoji, + label: options.t('title.updateContractAdmin'), + description: options.t('info.updateContractAdminActionDescription'), + }) + + this.defaults = { + chainId: options.chain.chain_id, + contract: '', + newAdmin: '', + } + } + + encode({ chainId, contract, newAdmin }: UpdateAdminData): UnifiedCosmosMsg[] { + return maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + chainId, + isSecretNetwork(chainId) + ? makeStargateMessage({ + stargate: { + typeUrl: SecretMsgUpdateAdmin.typeUrl, + value: SecretMsgUpdateAdmin.fromAmino({ + sender: + getChainAddressForActionOptions(this.options, chainId) || '', + contract, + new_admin: newAdmin, + }), + }, + }) + : { + wasm: { + update_admin: { + contract_addr: contract, + admin: newAdmin, + }, + }, + } + ) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return ( + isDecodedStargateMsg(decodedMessage, SecretMsgUpdateAdmin) || + objectMatchesStructure(decodedMessage, { + wasm: { + update_admin: { + contract_addr: {}, + admin: {}, + }, + }, + }) + ) + } + + decode([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): UpdateAdminData { + if (isDecodedStargateMsg(decodedMessage, SecretMsgUpdateAdmin)) { + return { + chainId, + contract: decodedMessage.stargate.value.contract, + newAdmin: decodedMessage.stargate.value.newAdmin, + } + } else { + return { + chainId, + contract: decodedMessage.wasm.update_admin.contract_addr, + newAdmin: decodedMessage.wasm.update_admin.admin, + } + } + } +} diff --git a/packages/stateful/actions/core/dao_appearance/UpdateInfo/Component.stories.tsx b/packages/stateful/actions/core/actions/UpdateInfo/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/dao_appearance/UpdateInfo/Component.stories.tsx rename to packages/stateful/actions/core/actions/UpdateInfo/Component.stories.tsx diff --git a/packages/stateful/actions/core/dao_appearance/UpdateInfo/Component.tsx b/packages/stateful/actions/core/actions/UpdateInfo/Component.tsx similarity index 98% rename from packages/stateful/actions/core/dao_appearance/UpdateInfo/Component.tsx rename to packages/stateful/actions/core/actions/UpdateInfo/Component.tsx index bf977a162..c830dcc93 100644 --- a/packages/stateful/actions/core/dao_appearance/UpdateInfo/Component.tsx +++ b/packages/stateful/actions/core/actions/UpdateInfo/Component.tsx @@ -9,6 +9,7 @@ import { InputLabel, TextAreaInput, TextInput, + useActionOptions, useDaoInfoContext, } from '@dao-dao/stateless' import { ChainId, ContractVersion } from '@dao-dao/types' @@ -21,7 +22,6 @@ import { } from '@dao-dao/utils' import { LinkWrapper, Trans } from '../../../../components' -import { useActionOptions } from '../../../react' export type UpdateInfoData = ConfigV1Response | ConfigV2Response diff --git a/packages/stateful/actions/core/dao_appearance/UpdateInfo/README.md b/packages/stateful/actions/core/actions/UpdateInfo/README.md similarity index 100% rename from packages/stateful/actions/core/dao_appearance/UpdateInfo/README.md rename to packages/stateful/actions/core/actions/UpdateInfo/README.md diff --git a/packages/stateful/actions/core/actions/UpdateInfo/index.tsx b/packages/stateful/actions/core/actions/UpdateInfo/index.tsx new file mode 100644 index 000000000..31736fe44 --- /dev/null +++ b/packages/stateful/actions/core/actions/UpdateInfo/index.tsx @@ -0,0 +1,131 @@ +import { daoDaoCoreQueries } from '@dao-dao/state' +import { ActionBase, InfoEmoji } from '@dao-dao/stateless' +import { + ActionContextType, + ChainId, + ContractVersion, + UnifiedCosmosMsg, +} from '@dao-dao/types' +import { + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { + makeExecuteSmartContractMessage, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { UpdateInfoComponent as Component, UpdateInfoData } from './Component' + +export class UpdateInfoAction extends ActionBase { + public readonly key = ActionKey.UpdateInfo + public readonly Component = Component + + constructor(options: ActionOptions) { + if (options.context.type !== ActionContextType.Dao) { + throw new Error('Only DAOs can update info.') + } + + super(options, { + Icon: InfoEmoji, + label: options.t('title.updateInfo'), + description: options.t('info.updateInfoActionDescription'), + }) + } + + async setup() { + this.defaults = await this.options.queryClient.fetchQuery( + daoDaoCoreQueries.config(this.options.queryClient, { + chainId: this.options.chain.chain_id, + contractAddress: this.options.address, + }) + ) + } + + encode(data: UpdateInfoData): UnifiedCosmosMsg { + // Type-check. Should be validated in the constructor. + if (this.options.context.type !== ActionContextType.Dao) { + throw new Error('Only DAOs can update info.') + } + + return makeExecuteSmartContractMessage({ + chainId: this.options.chain.chain_id, + sender: this.options.address, + contractAddress: this.options.address, + msg: { + update_config: { + config: + this.options.context.dao.chainId === ChainId.NeutronMainnet && + this.options.context.dao.coreVersion === + ContractVersion.V2AlphaNeutronFork + ? // The Neutron fork DAO has a different config structure. + { + name: data.name, + description: data.description, + dao_uri: 'dao_uri' in data ? data.dao_uri : null, + } + : { + ...data, + // Replace empty string with null. + image_url: data.image_url?.trim() || null, + }, + }, + }, + }) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return ( + objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + update_config: { + config: { + name: {}, + description: {}, + }, + }, + }, + }, + }, + }) && decodedMessage.wasm.execute.contract_addr === this.options.address + ) + } + + decode([ + { + decodedMessage: { + wasm: { + execute: { + msg: { + update_config: { config }, + }, + }, + }, + }, + }, + ]: ProcessedMessage[]): UpdateInfoData { + return { + name: config.name, + description: config.description, + automatically_add_cw20s: config.automatically_add_cw20s, + automatically_add_cw721s: config.automatically_add_cw721s, + + // Only add image url if in the message. + ...(!!config.image_url && { + image_url: config.image_url, + }), + + // V2 passthrough + // Only add dao URI if in the message. + ...('dao_uri' in config && { + dao_uri: config.dao_uri, + }), + } + } +} diff --git a/packages/stateful/actions/core/dao_governance/UpdatePreProposeConfig/Component.tsx b/packages/stateful/actions/core/actions/UpdatePreProposeConfig/Component.tsx similarity index 60% rename from packages/stateful/actions/core/dao_governance/UpdatePreProposeConfig/Component.tsx rename to packages/stateful/actions/core/actions/UpdatePreProposeConfig/Component.tsx index caef96486..390e53028 100644 --- a/packages/stateful/actions/core/dao_governance/UpdatePreProposeConfig/Component.tsx +++ b/packages/stateful/actions/core/actions/UpdatePreProposeConfig/Component.tsx @@ -3,11 +3,17 @@ import { ComponentType } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' -import { InputLabel, Loader, SegmentedControls } from '@dao-dao/stateless' +import { + ErrorPage, + InputLabel, + Loader, + SegmentedControls, +} from '@dao-dao/stateless' import { Action, ActionComponent, - ProposalModule, + LoadingDataWithError, + ProposalModuleInfo, SuspenseLoaderProps, TransProps, } from '@dao-dao/types' @@ -18,14 +24,12 @@ export type UpdatePreProposeConfigData = { } export type ProposalModuleWithAction = { - proposalModule: ProposalModule + proposalModule: ProposalModuleInfo action: Action } export type UpdatePreProposeConfigOptions = { - options: ProposalModuleWithAction[] - // Map proposal module address to defaults object. - defaults: Record> + options: LoadingDataWithError SuspenseLoader: ComponentType Trans: ComponentType } @@ -37,7 +41,7 @@ export const UpdatePreProposeConfigComponent: ActionComponent< const { fieldNamePrefix, isCreating, - options: { options, defaults, SuspenseLoader, Trans }, + options: { options, SuspenseLoader, Trans }, } = props const { watch, setValue } = useFormContext() @@ -45,9 +49,14 @@ export const UpdatePreProposeConfigComponent: ActionComponent< const proposalModuleAddress = watch( (fieldNamePrefix + 'proposalModuleAddress') as 'proposalModuleAddress' ) - const selected = options.find( - ({ proposalModule }) => proposalModule.address === proposalModuleAddress - ) + + const selected = + options.loading || options.errored + ? undefined + : options.data.find( + ({ proposalModule }) => + proposalModule.address === proposalModuleAddress + ) return ( <> @@ -70,26 +79,32 @@ export const UpdatePreProposeConfigComponent: ActionComponent< - { - setValue( - (fieldNamePrefix + - 'proposalModuleAddress') as 'proposalModuleAddress', - value - ) - setValue( - (fieldNamePrefix + 'data') as 'data', - cloneDeep(defaults[value] ?? {}) - ) - }} - selected={proposalModuleAddress} - tabs={options.map(({ proposalModule, action }) => ({ - label: action.label, - value: proposalModule.address, - }))} - /> + {options.loading ? ( + + ) : options.errored ? ( + + ) : ( + { + setValue( + (fieldNamePrefix + + 'proposalModuleAddress') as 'proposalModuleAddress', + proposalModule.address + ) + setValue( + (fieldNamePrefix + 'data') as 'data', + cloneDeep(action.defaults) + ) + }} + selected={selected} + tabs={options.data.map((option) => ({ + label: option.action.metadata.label, + value: option, + }))} + /> + )} diff --git a/packages/stateful/actions/core/actions/UpdatePreProposeConfig/index.tsx b/packages/stateful/actions/core/actions/UpdatePreProposeConfig/index.tsx new file mode 100644 index 000000000..36db79239 --- /dev/null +++ b/packages/stateful/actions/core/actions/UpdatePreProposeConfig/index.tsx @@ -0,0 +1,176 @@ +import { + ActionBase, + BallotDepositEmoji, + useActionOptions, + useLoadingPromise, +} from '@dao-dao/stateless' +import { + ActionComponent, + ActionContextType, + ActionEncodeContext, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types' + +import { SuspenseLoader, Trans } from '../../../../components' +import { matchAndLoadCommon } from '../../../../proposal-module-adapter' +import { + ProposalModuleWithAction, + UpdatePreProposeConfigComponent, + UpdatePreProposeConfigData, +} from './Component' + +const getUpdatePreProposeConfigActions = async ( + options: ActionOptions +): Promise => { + if (options.context.type !== ActionContextType.Dao) { + throw new Error('Not DAO context') + } + + const { dao } = options.context + + return ( + ( + await Promise.all( + dao.info.proposalModules.flatMap( + (proposalModule): Promise | [] => { + const action = matchAndLoadCommon( + dao, + proposalModule.address + ).fields.updatePreProposeConfigActionMaker?.(options) + + if (!action) { + return [] + } + + return Promise.resolve( + action.ready ? undefined : action.init() + ).then(() => ({ + proposalModule, + action, + })) + } + ) + ) + ) + // Sort proposal modules by prefix. + .sort((a, b) => + a.proposalModule.prefix.localeCompare(b.proposalModule.prefix) + ) + ) +} + +const Component: ActionComponent = (props) => { + const actionOptions = useActionOptions() + const options = useLoadingPromise({ + promise: async () => getUpdatePreProposeConfigActions(actionOptions), + deps: [actionOptions], + }) + + return ( + + ) +} + +export class UpdatePreProposeConfigAction extends ActionBase { + public readonly key = ActionKey.UpdatePreProposeConfig + public readonly Component = Component + + private actionOptions: ProposalModuleWithAction[] = [] + + constructor(options: ActionOptions) { + if (options.context.type !== ActionContextType.Dao) { + throw new Error('Not DAO context') + } + + super(options, { + Icon: BallotDepositEmoji, + label: options.t('form.updateProposalSubmissionConfigTitle'), + description: options.t( + 'info.updateProposalSubmissionConfigActionDescription' + ), + }) + } + + async setup() { + this.actionOptions = await getUpdatePreProposeConfigActions(this.options) + this.defaults = { + proposalModuleAddress: + this.actionOptions[0]?.proposalModule.address ?? '', + data: this.actionOptions[0]?.action.defaults ?? {}, + } + } + + encode( + { proposalModuleAddress, data }: UpdatePreProposeConfigData, + encodeContext: ActionEncodeContext + ) { + const option = this.actionOptions.find( + (option) => option.proposalModule.address === proposalModuleAddress + ) + if (!option) { + throw new Error( + this.options.t('error.failedToFindMatchingProposalModule') + ) + } + + return option.action.encode(data, encodeContext) + } + + // helper function used in both match and decode + async _match(messages: ProcessedMessage[]): Promise< + | { + option: ProposalModuleWithAction + match: ActionMatch + } + | undefined + > { + const matches = await Promise.allSettled( + this.actionOptions.map(async (option) => ({ + option, + match: await option.action.match(messages), + })) + ) + + const match = matches.flatMap((p) => + p.status === 'fulfilled' && p.value.match + ? [ + { + option: p.value.option, + match: p.value.match, + }, + ] + : [] + )[0] + + return match + } + + async match(messages: ProcessedMessage[]): Promise { + return (await this._match(messages))?.match || false + } + + async decode( + messages: ProcessedMessage[] + ): Promise { + const match = await this._match(messages) + // Should never happen since `match` confirms one of these options match. + if (!match) { + throw new Error('No matching action') + } + + return { + proposalModuleAddress: match.option.proposalModule.address, + data: await match.option.action.decode(messages), + } + } +} diff --git a/packages/stateful/actions/core/dao_governance/UpdateProposalConfig/Component.tsx b/packages/stateful/actions/core/actions/UpdateProposalConfig/Component.tsx similarity index 60% rename from packages/stateful/actions/core/dao_governance/UpdateProposalConfig/Component.tsx rename to packages/stateful/actions/core/actions/UpdateProposalConfig/Component.tsx index 98fc22ee4..d4f4663a1 100644 --- a/packages/stateful/actions/core/dao_governance/UpdateProposalConfig/Component.tsx +++ b/packages/stateful/actions/core/actions/UpdateProposalConfig/Component.tsx @@ -3,11 +3,17 @@ import { ComponentType } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' -import { InputLabel, Loader, SegmentedControls } from '@dao-dao/stateless' +import { + ErrorPage, + InputLabel, + Loader, + SegmentedControls, +} from '@dao-dao/stateless' import { Action, ActionComponent, - ProposalModule, + LoadingDataWithError, + ProposalModuleInfo, SuspenseLoaderProps, TransProps, } from '@dao-dao/types' @@ -18,14 +24,12 @@ export type UpdateProposalConfigData = { } export type ProposalModuleWithAction = { - proposalModule: ProposalModule + proposalModule: ProposalModuleInfo action: Action } export type UpdateProposalConfigOptions = { - options: ProposalModuleWithAction[] - // Map proposal module address to defaults object. - defaults: Record> + options: LoadingDataWithError SuspenseLoader: ComponentType Trans: ComponentType } @@ -37,7 +41,7 @@ export const UpdateProposalConfigComponent: ActionComponent< const { fieldNamePrefix, isCreating, - options: { options, defaults, SuspenseLoader, Trans }, + options: { options, SuspenseLoader, Trans }, } = props const { watch, setValue } = useFormContext() @@ -45,9 +49,14 @@ export const UpdateProposalConfigComponent: ActionComponent< const proposalModuleAddress = watch( (fieldNamePrefix + 'proposalModuleAddress') as 'proposalModuleAddress' ) - const selected = options.find( - ({ proposalModule }) => proposalModule.address === proposalModuleAddress - ) + + const selected = + options.loading || options.errored + ? undefined + : options.data.find( + ({ proposalModule }) => + proposalModule.address === proposalModuleAddress + ) return ( <> @@ -70,26 +79,32 @@ export const UpdateProposalConfigComponent: ActionComponent< - { - setValue( - (fieldNamePrefix + - 'proposalModuleAddress') as 'proposalModuleAddress', - value - ) - setValue( - (fieldNamePrefix + 'data') as 'data', - cloneDeep(defaults[value] ?? {}) - ) - }} - selected={proposalModuleAddress} - tabs={options.map(({ proposalModule, action }) => ({ - label: action.label, - value: proposalModule.address, - }))} - /> + {options.loading ? ( + + ) : options.errored ? ( + + ) : ( + { + setValue( + (fieldNamePrefix + + 'proposalModuleAddress') as 'proposalModuleAddress', + proposalModule.address + ) + setValue( + (fieldNamePrefix + 'data') as 'data', + cloneDeep(action.defaults) + ) + }} + selected={selected} + tabs={options.data.map((option) => ({ + label: option.action.metadata.label, + value: option, + }))} + /> + )} diff --git a/packages/stateful/actions/core/actions/UpdateProposalConfig/index.tsx b/packages/stateful/actions/core/actions/UpdateProposalConfig/index.tsx new file mode 100644 index 000000000..2e239da89 --- /dev/null +++ b/packages/stateful/actions/core/actions/UpdateProposalConfig/index.tsx @@ -0,0 +1,174 @@ +import { + ActionBase, + BallotDepositEmoji, + useActionOptions, + useLoadingPromise, +} from '@dao-dao/stateless' +import { + ActionComponent, + ActionContextType, + ActionEncodeContext, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types' + +import { SuspenseLoader, Trans } from '../../../../components' +import { matchAndLoadCommon } from '../../../../proposal-module-adapter' +import { + ProposalModuleWithAction, + UpdateProposalConfigComponent, + UpdateProposalConfigData, +} from './Component' + +const getUpdateProposalConfigActions = async ( + options: ActionOptions +): Promise => { + if (options.context.type !== ActionContextType.Dao) { + throw new Error('Not DAO context') + } + + const { dao } = options.context + + return ( + ( + await Promise.all( + dao.info.proposalModules.flatMap( + (proposalModule): Promise | [] => { + const action = matchAndLoadCommon( + dao, + proposalModule.address + ).fields.updateConfigActionMaker(options) + + if (!action) { + return [] + } + + return Promise.resolve( + action.ready ? undefined : action.init() + ).then(() => ({ + proposalModule, + action, + })) + } + ) + ) + ) + // Sort proposal modules by prefix. + .sort((a, b) => + a.proposalModule.prefix.localeCompare(b.proposalModule.prefix) + ) + ) +} + +const Component: ActionComponent = (props) => { + const actionOptions = useActionOptions() + const options = useLoadingPromise({ + promise: async () => getUpdateProposalConfigActions(actionOptions), + deps: [actionOptions], + }) + + return ( + + ) +} + +export class UpdateProposalConfigAction extends ActionBase { + public readonly key = ActionKey.UpdateProposalConfig + public readonly Component = Component + + private actionOptions: ProposalModuleWithAction[] = [] + + constructor(options: ActionOptions) { + if (options.context.type !== ActionContextType.Dao) { + throw new Error('Not DAO context') + } + + super(options, { + Icon: BallotDepositEmoji, + label: options.t('form.updateVotingConfigTitle'), + description: options.t('info.updateVotingConfigActionDescription'), + }) + } + + async setup() { + this.actionOptions = await getUpdateProposalConfigActions(this.options) + this.defaults = { + proposalModuleAddress: + this.actionOptions[0]?.proposalModule.address ?? '', + data: this.actionOptions[0]?.action.defaults ?? {}, + } + } + + encode( + { proposalModuleAddress, data }: UpdateProposalConfigData, + encodeContext: ActionEncodeContext + ) { + const option = this.actionOptions.find( + (option) => option.proposalModule.address === proposalModuleAddress + ) + if (!option) { + throw new Error( + this.options.t('error.failedToFindMatchingProposalModule') + ) + } + + return option.action.encode(data, encodeContext) + } + + // helper function used in both match and decode + async _match(messages: ProcessedMessage[]): Promise< + | { + option: ProposalModuleWithAction + match: ActionMatch + } + | undefined + > { + const matches = await Promise.allSettled( + this.actionOptions.map(async (option) => ({ + option, + match: await option.action.match(messages), + })) + ) + + const match = matches.flatMap((p) => + p.status === 'fulfilled' && p.value.match + ? [ + { + option: p.value.option, + match: p.value.match, + }, + ] + : [] + )[0] + + return match + } + + async match(messages: ProcessedMessage[]): Promise { + return (await this._match(messages))?.match || false + } + + async decode( + messages: ProcessedMessage[] + ): Promise { + const match = await this._match(messages) + // Should never happen since `match` confirms one of these options match. + if (!match) { + throw new Error('No matching action') + } + + return { + proposalModuleAddress: match.option.proposalModule.address, + data: await match.option.action.decode(messages), + } + } +} diff --git a/packages/stateful/actions/core/dao_governance/UpgradeV1ToV2/Component.stories.tsx b/packages/stateful/actions/core/actions/UpgradeV1ToV2/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/dao_governance/UpgradeV1ToV2/Component.stories.tsx rename to packages/stateful/actions/core/actions/UpgradeV1ToV2/Component.stories.tsx diff --git a/packages/stateful/actions/core/dao_governance/UpgradeV1ToV2/Component.tsx b/packages/stateful/actions/core/actions/UpgradeV1ToV2/Component.tsx similarity index 99% rename from packages/stateful/actions/core/dao_governance/UpgradeV1ToV2/Component.tsx rename to packages/stateful/actions/core/actions/UpgradeV1ToV2/Component.tsx index 4affea396..6a71be1b1 100644 --- a/packages/stateful/actions/core/dao_governance/UpgradeV1ToV2/Component.tsx +++ b/packages/stateful/actions/core/actions/UpgradeV1ToV2/Component.tsx @@ -10,6 +10,7 @@ import { InputErrorMessage, InputLabel, RadioInput, + useActionOptions, } from '@dao-dao/stateless' import { ActionComponent, @@ -22,8 +23,6 @@ import { import { SubDao } from '@dao-dao/types/contracts/DaoDaoCore' import { makeValidateAddress, validateRequired } from '@dao-dao/utils' -import { useActionOptions } from '../../../react' - export interface UpgradeV1ToV2Data { targetAddress: string subDaos: SubDao[] diff --git a/packages/stateful/actions/core/dao_governance/UpgradeV1ToV2/README.md b/packages/stateful/actions/core/actions/UpgradeV1ToV2/README.md similarity index 100% rename from packages/stateful/actions/core/dao_governance/UpgradeV1ToV2/README.md rename to packages/stateful/actions/core/actions/UpgradeV1ToV2/README.md diff --git a/packages/stateful/actions/core/actions/UpgradeV1ToV2/index.tsx b/packages/stateful/actions/core/actions/UpgradeV1ToV2/index.tsx new file mode 100644 index 000000000..7adb7dd2f --- /dev/null +++ b/packages/stateful/actions/core/actions/UpgradeV1ToV2/index.tsx @@ -0,0 +1,355 @@ +import { QueryClient, useQueryClient } from '@tanstack/react-query' +import uniq from 'lodash.uniq' + +import { daoQueries } from '@dao-dao/state/query' +import { + ActionBase, + ErrorPage, + Loader, + UnicornEmoji, + useActionOptions, + useLoadingPromise, +} from '@dao-dao/stateless' +import { + ActionChainContextType, + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ContractVersion, + IDaoBase, + ProcessedMessage, + UnifiedCosmosMsg, +} from '@dao-dao/types' +import { PreProposeInfo } from '@dao-dao/types/contracts/DaoProposalSingle.v2' +import { + encodeJsonToBase64, + makeWasmMessage, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { getDao } from '../../../../clients' +import { AddressInput, EntityDisplay } from '../../../../components' +import { UpgradeV1ToV2Component, UpgradeV1ToV2Data } from './Component' + +const getSubDaos = async ( + queryClient: QueryClient, + chainId: string, + address: string +): Promise => { + // Merge registered SubDAOs and potential SubDAOs from indexer. + const subDaoAddresses = uniq( + ( + await Promise.all([ + queryClient + .fetchQuery( + daoQueries.listAllSubDaos(queryClient, { + chainId, + address, + }) + // This action only supports same-chain upgrades right now. + ) + .then((subDaos) => + subDaos.flatMap((d) => (d.chainId === chainId ? d.addr : [])) + ) + // Fails for V1 DAOs, which don't support SubDAOs. + .catch(() => []), + queryClient + .fetchQuery( + daoQueries.listPotentialSubDaos(queryClient, { + chainId, + address, + }) + ) + // Fails for chains without indexers. + .catch(() => []), + ]) + ).flat() + ) + + const subDaos = ( + await Promise.allSettled( + subDaoAddresses.map(async (coreAddress) => { + const dao = getDao({ + queryClient, + chainId, + coreAddress, + }) + await dao.init() + return dao + }) + ) + ).flatMap((l) => (l.status === 'fulfilled' ? l.value : [])) + + return subDaos +} + +const Component: ActionComponent = (props) => { + const { + chain: { chain_id: chainId }, + address, + context, + } = useActionOptions() + const queryClient = useQueryClient() + const v1SubDaos = useLoadingPromise({ + promise: async () => + (await getSubDaos(queryClient, chainId, address)).filter( + (d) => d.coreVersion === ContractVersion.V1 + ), + deps: [queryClient, chainId, address], + }) + + return v1SubDaos.loading ? ( + + ) : v1SubDaos.errored ? ( + + ) : ( + + ) +} + +export class UpgradeV1ToV2Action extends ActionBase { + public readonly key = ActionKey.UpgradeV1ToV2 + public readonly Component = Component + + private subDaos: IDaoBase[] = [] + + constructor(options: ActionOptions) { + if (options.context.type !== ActionContextType.Dao) { + throw new Error('Not DAO context') + } + + // If no DAO migrator, don't show upgrade action. + if ( + options.chainContext.type !== ActionChainContextType.Supported || + !options.chainContext.config.codeIds.DaoMigrator + ) { + throw new Error('No DAO migrator on this chain') + } + + super(options, { + Icon: UnicornEmoji, + label: options.t('title.upgradeToV2'), + description: options.t('info.upgradeToV2Description'), + // Hide by default and reveal if this DAO is V1 or there are V1 SubDAOs to + // upgrade, in setup. + hideFromPicker: true, + notReusable: true, + }) + + // Fire async init immediately since we may show this action. + this.init().catch(() => {}) + } + + async setup() { + // Type-check. Should never happen since this is checked in the constructor. + if (this.options.context.type !== ActionContextType.Dao) { + throw new Error('Not DAO context') + } + + this.subDaos = await getSubDaos( + this.options.queryClient, + this.options.chain.chain_id, + this.options.address + ) + + // Hide from picker if current DAO is not on v1 and there are no V1 SubDAOs + // to upgrade. + this.metadata.hideFromPicker = + this.options.context.dao.coreVersion !== ContractVersion.V1 && + this.subDaos.filter((s) => s.coreVersion === ContractVersion.V1) + .length === 0 + + this.defaults = { + targetAddress: + // If DAO is not on v1, don't default to the DAO address. + this.options.context.dao.coreVersion === ContractVersion.V1 + ? this.options.address + : '', + subDaos: this.subDaos.map(({ coreAddress }) => ({ + addr: coreAddress, + })), + } + } + + async encode({ + targetAddress, + subDaos, + }: UpgradeV1ToV2Data): Promise { + // Type-check. Should never happen since this is checked in the constructor. + if (this.options.context.type !== ActionContextType.Dao) { + throw new Error('Not DAO context') + } + + // Type-check. Should never happen since this is checked in the constructor. + if (this.options.chainContext.type !== ActionChainContextType.Supported) { + throw new Error('No DAO migrator on this chain') + } + + const dao = + targetAddress === this.options.address + ? this.options.context.dao + : this.subDaos.find(({ coreAddress }) => coreAddress === targetAddress) + if (!dao) { + throw new Error('Invalid target DAO') + } + + // The deposit infos are ordered to match the proposal modules in the DAO + // core list, which is what the migration contract expects. + const proposalModuleDepositInfos = await Promise.all( + dao.proposalModules.map(async (p) => ({ + address: p.address, + depositInfo: await this.options.queryClient.fetchQuery( + p.getDepositInfoQuery() + ), + })) + ) + + const { + DaoCore, + DaoMigrator, + DaoPreProposeSingle, + DaoProposalSingle, + DaoVotingCw4, + Cw20Stake, + DaoVotingCw20Staked, + } = this.options.chainContext.config.codeIds + + // Array of tuples of each proposal module address and its params. + const proposalParams = proposalModuleDepositInfos.map( + ({ address, depositInfo }, index) => [ + address, + { + close_proposal_on_execution_failure: true, + pre_propose_info: { + module_may_propose: { + info: { + admin: { core_module: {} }, + code_id: DaoPreProposeSingle, + label: `dao-pre-propose-single_${index}_${Date.now()}`, + funds: [], + msg: encodeJsonToBase64({ + deposit_info: depositInfo + ? { + amount: depositInfo.amount, + denom: { + token: { + denom: depositInfo.denom, + }, + }, + refund_policy: depositInfo.refund_policy, + } + : null, + extension: {}, + open_proposal_submission: false, + }), + }, + }, + }, + }, + ] + ) as [ + string, + { + close_proposal_on_execution_failure: boolean + pre_propose_info: PreProposeInfo + } + ][] + + return makeWasmMessage({ + wasm: { + migrate: { + contract_addr: targetAddress, + new_code_id: DaoCore, + msg: { + from_v1: { + dao_uri: `https://daodao.zone/dao/${targetAddress}`, + params: { + migrator_code_id: DaoMigrator, + params: { + sub_daos: subDaos, + migration_params: { + migrate_stake_cw20_manager: true, + proposal_params: proposalParams, + }, + v1_code_ids: { + proposal_single: 427, + cw4_voting: 429, + cw20_stake: 430, + cw20_staked_balances_voting: 431, + }, + v2_code_ids: { + proposal_single: DaoProposalSingle, + cw4_voting: DaoVotingCw4, + cw20_stake: Cw20Stake, + cw20_staked_balances_voting: DaoVotingCw20Staked, + }, + }, + }, + }, + }, + }, + }, + }) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return objectMatchesStructure(decodedMessage, { + wasm: { + migrate: { + contract_addr: {}, + new_code_id: {}, + msg: { + from_v1: { + dao_uri: {}, + params: { + migrator_code_id: {}, + params: { + sub_daos: {}, + migration_params: { + migrate_stake_cw20_manager: {}, + proposal_params: {}, + }, + v1_code_ids: { + proposal_single: {}, + cw4_voting: {}, + cw20_stake: {}, + cw20_staked_balances_voting: {}, + }, + v2_code_ids: { + proposal_single: {}, + cw4_voting: {}, + cw20_stake: {}, + cw20_staked_balances_voting: {}, + }, + }, + }, + }, + }, + }, + }, + }) + } + + decode([{ decodedMessage }]: ProcessedMessage[]): UpgradeV1ToV2Data { + return { + targetAddress: decodedMessage.wasm.migrate.contract_addr, + subDaos: decodedMessage.wasm.migrate.msg.from_v1.params.params.sub_daos, + } + } +} diff --git a/packages/stateful/actions/core/smart_contracting/UploadCode/Component.stories.tsx b/packages/stateful/actions/core/actions/UploadCode/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/smart_contracting/UploadCode/Component.stories.tsx rename to packages/stateful/actions/core/actions/UploadCode/Component.stories.tsx diff --git a/packages/stateful/actions/core/smart_contracting/UploadCode/Component.tsx b/packages/stateful/actions/core/actions/UploadCode/Component.tsx similarity index 100% rename from packages/stateful/actions/core/smart_contracting/UploadCode/Component.tsx rename to packages/stateful/actions/core/actions/UploadCode/Component.tsx diff --git a/packages/stateful/actions/core/smart_contracting/UploadCode/README.md b/packages/stateful/actions/core/actions/UploadCode/README.md similarity index 100% rename from packages/stateful/actions/core/smart_contracting/UploadCode/README.md rename to packages/stateful/actions/core/actions/UploadCode/README.md diff --git a/packages/stateful/actions/core/actions/UploadCode/index.tsx b/packages/stateful/actions/core/actions/UploadCode/index.tsx new file mode 100644 index 000000000..28778a950 --- /dev/null +++ b/packages/stateful/actions/core/actions/UploadCode/index.tsx @@ -0,0 +1,155 @@ +import { fromBase64, fromBech32, toBase64 } from '@cosmjs/encoding' +import { Trans } from 'react-i18next' + +import { + ActionBase, + ComputerDiskEmoji, + DaoSupportedChainPickerInput, + useActionOptions, +} from '@dao-dao/stateless' +import { UnifiedCosmosMsg, makeStargateMessage } from '@dao-dao/types' +import { + ActionComponent, + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { MsgStoreCode } from '@dao-dao/types/protobuf/codegen/cosmwasm/wasm/v1/tx' +import { AccessType } from '@dao-dao/types/protobuf/codegen/cosmwasm/wasm/v1/types' +import { MsgStoreCode as SecretMsgStoreCode } from '@dao-dao/types/protobuf/codegen/secret/compute/v1beta1/msg' +import { + getChainAddressForActionOptions, + isDecodedStargateMsg, + isGzipped, + isSecretNetwork, + maybeMakePolytoneExecuteMessages, +} from '@dao-dao/utils' + +import { AddressInput } from '../../../../components' +import { UploadCodeComponent, UploadCodeData } from './Component' + +const Component: ActionComponent = (props) => { + const { context } = useActionOptions() + + return ( + <> + {context.type === ActionContextType.Dao && ( + + )} + + + + ) +} + +export class UploadCodeAction extends ActionBase { + public readonly key = ActionKey.UploadCode + public readonly Component = Component + + constructor(options: ActionOptions) { + super(options, { + Icon: ComputerDiskEmoji, + label: options.t('title.uploadSmartContractCode'), + description: options.t('info.uploadSmartContractCodeActionDescription'), + // DAO proposal modules limit the sizes of proposals such that uploading + // almost any wasm file is impossible. Until we figure out a workaround, + // hide it from being selected in a DAO. Wallets and chain governance + // proposals can still use this. + hideFromPicker: options.context.type === ActionContextType.Dao, + }) + + this.defaults = { + chainId: options.chain.chain_id, + accessType: AccessType.Everybody, + allowedAddresses: [{ address: options.address }], + } + } + + encode({ + chainId, + data, + accessType, + allowedAddresses, + }: UploadCodeData): UnifiedCosmosMsg[] { + if (!data) { + return [] + } + + const sender = getChainAddressForActionOptions(this.options, chainId) + if (!sender) { + throw new Error('No sender found for chain') + } + + return maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + chainId, + makeStargateMessage({ + stargate: isSecretNetwork(chainId) + ? { + typeUrl: SecretMsgStoreCode.typeUrl, + value: SecretMsgStoreCode.fromPartial({ + sender: fromBech32(sender).data, + wasmByteCode: fromBase64(data), + }), + } + : { + typeUrl: MsgStoreCode.typeUrl, + value: MsgStoreCode.fromPartial({ + sender, + wasmByteCode: fromBase64(data), + instantiatePermission: { + permission: accessType, + addresses: + accessType === AccessType.AnyOfAddresses + ? allowedAddresses.map(({ address }) => address) + : [], + }, + }), + }, + }) + ) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return isDecodedStargateMsg(decodedMessage, [ + MsgStoreCode, + SecretMsgStoreCode, + ]) + } + + decode([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): UploadCodeData { + const wasmByteCode = decodedMessage.stargate.value + .wasmByteCode as Uint8Array + const gzipped = + wasmByteCode instanceof Uint8Array && isGzipped(wasmByteCode) + + return { + chainId, + data: toBase64(wasmByteCode), + gzipped, + accessType: + decodedMessage.stargate.value.instantiatePermission?.permission ?? + AccessType.UNRECOGNIZED, + allowedAddresses: + decodedMessage.stargate.value.instantiatePermission?.addresses?.map( + (address: string) => ({ address }) + ) ?? [], + } + } +} diff --git a/packages/stateful/actions/core/chain_governance/ValidatorActions/Component.stories.tsx b/packages/stateful/actions/core/actions/ValidatorActions/Component.stories.tsx similarity index 100% rename from packages/stateful/actions/core/chain_governance/ValidatorActions/Component.stories.tsx rename to packages/stateful/actions/core/actions/ValidatorActions/Component.stories.tsx diff --git a/packages/stateful/actions/core/chain_governance/ValidatorActions/Component.tsx b/packages/stateful/actions/core/actions/ValidatorActions/Component.tsx similarity index 99% rename from packages/stateful/actions/core/chain_governance/ValidatorActions/Component.tsx rename to packages/stateful/actions/core/actions/ValidatorActions/Component.tsx index 51e064731..5e50d0089 100644 --- a/packages/stateful/actions/core/chain_governance/ValidatorActions/Component.tsx +++ b/packages/stateful/actions/core/actions/ValidatorActions/Component.tsx @@ -7,6 +7,7 @@ import { DaoSupportedChainPickerInput, InputLabel, SelectInput, + useActionOptions, } from '@dao-dao/stateless' import { ActionComponent, ActionContextType } from '@dao-dao/types/actions' import { MsgWithdrawValidatorCommission } from '@dao-dao/types/protobuf/codegen/cosmos/distribution/v1beta1/tx' @@ -23,8 +24,6 @@ import { validateJSON, } from '@dao-dao/utils' -import { useActionOptions } from '../../../react' - export const VALIDATOR_ACTION_TYPES = [ { typeUrl: MsgWithdrawValidatorCommission.typeUrl, diff --git a/packages/stateful/actions/core/chain_governance/ValidatorActions/README.md b/packages/stateful/actions/core/actions/ValidatorActions/README.md similarity index 100% rename from packages/stateful/actions/core/chain_governance/ValidatorActions/README.md rename to packages/stateful/actions/core/actions/ValidatorActions/README.md diff --git a/packages/stateful/actions/core/actions/ValidatorActions/index.tsx b/packages/stateful/actions/core/actions/ValidatorActions/index.tsx new file mode 100644 index 000000000..a65001cad --- /dev/null +++ b/packages/stateful/actions/core/actions/ValidatorActions/index.tsx @@ -0,0 +1,320 @@ +import { fromBase64, toBase64 } from '@cosmjs/encoding' + +import { ActionBase, PickEmoji } from '@dao-dao/stateless' +import { ChainId, UnifiedCosmosMsg, makeStargateMessage } from '@dao-dao/types' +import { + ActionContextType, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { PubKey } from '@dao-dao/types/protobuf/codegen/cosmos/crypto/ed25519/keys' +import { MsgWithdrawValidatorCommission } from '@dao-dao/types/protobuf/codegen/cosmos/distribution/v1beta1/tx' +import { MsgUnjail } from '@dao-dao/types/protobuf/codegen/cosmos/slashing/v1beta1/tx' +import { + MsgCreateValidator, + MsgEditValidator, +} from '@dao-dao/types/protobuf/codegen/cosmos/staking/v1beta1/tx' +import { + getChainAddressForActionOptions, + getChainForChainId, + getNativeTokenForChainId, + isDecodedStargateMsg, + maybeMakePolytoneExecuteMessages, + toValidatorAddress, +} from '@dao-dao/utils' + +import { + VALIDATOR_ACTION_TYPES, + ValidatorActionsComponent, + ValidatorActionsData, +} from './Component' + +export class ValidatorActionsAction extends ActionBase { + public readonly key = ActionKey.ValidatorActions + public readonly Component = ValidatorActionsComponent + + constructor(options: ActionOptions) { + // Governance module cannot run a validator. + if (options.context.type === ActionContextType.Gov) { + throw new Error( + 'Validator actions are not available from chain governance' + ) + } + + // Neutron does not have validators. + if ( + options.chain.chain_id === ChainId.NeutronMainnet || + options.chain.chain_id === ChainId.NeutronTestnet + ) { + throw new Error('Validator actions are not available on Neutron') + } + + super(options, { + Icon: PickEmoji, + label: options.t('title.validatorActions'), + description: options.t('info.validatorActionsDescription'), + }) + } + + setup() { + this.defaults = { + chainId: this.options.chain.chain_id, + validatorActionTypeUrl: VALIDATOR_ACTION_TYPES[0].typeUrl, + createMsg: JSON.stringify( + { + description: { + moniker: '', + identity: '', + website: '', + securityContact: '', + details: '', + }, + commission: { + rate: '0.05', + maxRate: '0.2', + maxChangeRate: '0.1', + }, + minSelfDelegation: '1', + delegatorAddress: getChainAddressForActionOptions( + this.options, + this.options.chain.chain_id + ), + validatorAddress: this.getValidatorAddress( + this.options.chain.chain_id + ), + pubkey: { + typeUrl: PubKey.typeUrl, + value: { + key: '', + }, + }, + value: { + denom: getNativeTokenForChainId(this.options.chain.chain_id) + .denomOrAddress, + amount: '1000000', + }, + }, + null, + 2 + ), + editMsg: JSON.stringify( + { + description: { + moniker: '', + identity: '', + website: '', + securityContact: '', + details: '', + }, + commissionRate: '0.05', + minSelfDelegation: '1', + validatorAddress: this.getValidatorAddress( + this.options.chain.chain_id + ), + }, + null, + 2 + ), + } + } + + getValidatorAddress(chainId: string) { + return toValidatorAddress( + getChainAddressForActionOptions(this.options, chainId) || '', + getChainForChainId(chainId).bech32_prefix + ) + } + + encode({ + chainId, + validatorActionTypeUrl, + createMsg, + editMsg, + }: ValidatorActionsData): UnifiedCosmosMsg[] { + const validatorAddress = this.getValidatorAddress(chainId) + + let msg + switch (validatorActionTypeUrl) { + case MsgWithdrawValidatorCommission.typeUrl: + msg = makeStargateMessage({ + stargate: { + typeUrl: MsgWithdrawValidatorCommission.typeUrl, + value: { + validatorAddress, + } as MsgWithdrawValidatorCommission, + }, + }) + break + case MsgCreateValidator.typeUrl: + const parsed = JSON.parse(createMsg) + msg = makeStargateMessage({ + stargate: { + typeUrl: MsgCreateValidator.typeUrl, + value: { + ...parsed, + pubkey: PubKey.toProtoMsg({ + key: fromBase64(parsed.pubkey.value.key), + }), + }, + }, + }) + break + case MsgEditValidator.typeUrl: + msg = makeStargateMessage({ + stargate: { + typeUrl: MsgEditValidator.typeUrl, + value: JSON.parse(editMsg), + }, + }) + break + case MsgUnjail.typeUrl: + msg = makeStargateMessage({ + stargate: { + typeUrl: MsgUnjail.typeUrl, + value: { + validatorAddr: validatorAddress, + } as MsgUnjail, + }, + }) + break + default: + throw Error('Unrecogonized validator action type') + } + + return maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + chainId, + msg + ) + } + + match([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): ActionMatch { + const thisAddress = getChainAddressForActionOptions(this.options, chainId) + const validatorAddress = this.getValidatorAddress(chainId) + + if ( + !thisAddress || + // Ensure this is a stargate message. + !isDecodedStargateMsg(decodedMessage, VALIDATOR_ACTION_TYPES) + ) { + return false + } + + switch (decodedMessage.stargate.typeUrl) { + case MsgWithdrawValidatorCommission.typeUrl: { + if ( + (decodedMessage.stargate.value as MsgWithdrawValidatorCommission) + .validatorAddress !== validatorAddress + ) { + return false + } + + break + } + + case MsgCreateValidator.typeUrl: { + if ( + (decodedMessage.stargate.value as MsgCreateValidator) + .delegatorAddress !== thisAddress || + (decodedMessage.stargate.value as MsgCreateValidator) + .validatorAddress !== validatorAddress + ) { + return false + } + + break + } + + case MsgEditValidator.typeUrl: { + if ( + (decodedMessage.stargate.value as MsgEditValidator) + .validatorAddress !== validatorAddress + ) { + return false + } + + break + } + + case MsgUnjail.typeUrl: { + if ( + (decodedMessage.stargate.value as MsgUnjail).validatorAddr !== + validatorAddress + ) { + return false + } + + break + } + + default: + // No validator action type URL match, so return a false match. + return false + } + + return true + } + + decode([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): Partial { + switch (decodedMessage.stargate.typeUrl) { + case MsgWithdrawValidatorCommission.typeUrl: + return { + chainId, + validatorActionTypeUrl: MsgWithdrawValidatorCommission.typeUrl, + } + + case MsgCreateValidator.typeUrl: + return { + chainId, + validatorActionTypeUrl: MsgCreateValidator.typeUrl, + createMsg: JSON.stringify( + { + ...decodedMessage.stargate.value, + pubkey: { + typeUrl: decodedMessage.stargate.value.pubkey!.typeUrl, + value: { + key: toBase64( + PubKey.decode( + (decodedMessage.stargate.value as MsgCreateValidator) + .pubkey!.value + ).key + ), + }, + }, + }, + null, + 2 + ), + } + + case MsgEditValidator.typeUrl: + return { + chainId, + validatorActionTypeUrl: MsgEditValidator.typeUrl, + editMsg: JSON.stringify(decodedMessage.stargate.value, null, 2), + } + + case MsgUnjail.typeUrl: + return { + chainId, + validatorActionTypeUrl: MsgUnjail.typeUrl, + } + + default: + // Should never happen since this is validated in match. + throw new Error('Unrecognized validator action type') + } + } +} diff --git a/packages/stateful/actions/core/dao_governance/VetoOrEarlyExecuteDaoProposal/Component.stories.tsx b/packages/stateful/actions/core/actions/VetoProposal/Component.stories.tsx similarity index 65% rename from packages/stateful/actions/core/dao_governance/VetoOrEarlyExecuteDaoProposal/Component.stories.tsx rename to packages/stateful/actions/core/actions/VetoProposal/Component.stories.tsx index 0e5e66e99..1536d809b 100644 --- a/packages/stateful/actions/core/dao_governance/VetoOrEarlyExecuteDaoProposal/Component.stories.tsx +++ b/packages/stateful/actions/core/actions/VetoProposal/Component.stories.tsx @@ -7,18 +7,18 @@ import { EntityDisplay, ProposalLine, } from '../../../../components' -import { VetoOrEarlyExecuteDaoProposalComponent } from './Component' +import { VetoProposalComponent } from './Component' export default { title: - 'DAO DAO / packages / stateful / actions / core / dao_governance / VetoOrEarlyExecuteDaoProposal', - component: VetoOrEarlyExecuteDaoProposalComponent, + 'DAO DAO / packages / stateful / actions / core / dao_governance / VetoProposal', + component: VetoProposalComponent, decorators: [ReactHookFormDecorator], -} as ComponentMeta +} as ComponentMeta -const Template: ComponentStory< - typeof VetoOrEarlyExecuteDaoProposalComponent -> = (args) => +const Template: ComponentStory = (args) => ( + +) export const Default = Template.bind({}) Default.args = { diff --git a/packages/stateful/actions/core/dao_governance/VetoOrEarlyExecuteDaoProposal/Component.tsx b/packages/stateful/actions/core/actions/VetoProposal/Component.tsx similarity index 87% rename from packages/stateful/actions/core/dao_governance/VetoOrEarlyExecuteDaoProposal/Component.tsx rename to packages/stateful/actions/core/actions/VetoProposal/Component.tsx index 1411a9ec5..acfccfc1c 100644 --- a/packages/stateful/actions/core/dao_governance/VetoOrEarlyExecuteDaoProposal/Component.tsx +++ b/packages/stateful/actions/core/actions/VetoProposal/Component.tsx @@ -1,5 +1,5 @@ import { CheckBoxOutlineBlankRounded } from '@mui/icons-material' -import { ChangeEvent, ComponentType, useState } from 'react' +import { ChangeEvent, ComponentType, useEffect, useState } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' @@ -11,7 +11,6 @@ import { InputLabel, Loader, NoContent, - SegmentedControls, TextInput, useDaoNavHelpers, } from '@dao-dao/stateless' @@ -25,35 +24,28 @@ import { } from '@dao-dao/types' import { ActionComponent } from '@dao-dao/types/actions' import { + extractProposalInfo, getChainForChainId, makeValidateAddress, validateRequired, } from '@dao-dao/utils' -export type VetoOrEarlyExecuteDaoProposalData = { +export type VetoProposalData = { chainId: string coreAddress: string proposalModuleAddress: string proposalId: number - action: 'veto' | 'earlyExecute' - // If defined, this is the vetoer address and it is a cw1-whitelist. This is - // needed in order to correctly format a cw1-whitelist message. - cw1WhitelistVetoer?: string } -export type VetoOrEarlyExecuteDaoProposalOptions = { +export type VetoProposalOptions = { selectedDaoInfo: LoadingDataWithError daoVetoableProposals: LoadingDataWithError - AddressInput: ComponentType< - AddressInputProps - > + AddressInput: ComponentType> EntityDisplay: ComponentType ProposalLine: ComponentType } -export const VetoOrEarlyExecuteDaoProposalComponent: ActionComponent< - VetoOrEarlyExecuteDaoProposalOptions -> = ({ +export const VetoProposalComponent: ActionComponent = ({ fieldNamePrefix, errors, isCreating, @@ -66,8 +58,7 @@ export const VetoOrEarlyExecuteDaoProposalComponent: ActionComponent< }, }) => { const { t } = useTranslation() - const { register, watch, setValue } = - useFormContext() + const { register, watch, setValue } = useFormContext() const { getDaoProposalPath } = useDaoNavHelpers() const chainId = watch((fieldNamePrefix + 'chainId') as 'chainId') @@ -119,27 +110,37 @@ export const VetoOrEarlyExecuteDaoProposalComponent: ActionComponent< const [manualProposalId, setManualProposalId] = useState('') + // Update fields when manual proposal ID is changed. + useEffect(() => { + if ( + !manualProposalId || + selectedDaoInfo.loading || + selectedDaoInfo.errored + ) { + return + } + + try { + const { prefix, proposalNumber } = extractProposalInfo(manualProposalId) + const proposalModule = selectedDaoInfo.data.proposalModules.find( + (m) => m.prefix === prefix + ) + if (proposalModule) { + setValue( + (fieldNamePrefix + + 'proposalModuleAddress') as 'proposalModuleAddress', + proposalModule.address + ) + setValue( + (fieldNamePrefix + 'proposalId') as 'proposalId', + proposalNumber + ) + } + } catch {} + }, [fieldNamePrefix, manualProposalId, selectedDaoInfo, setValue]) + return ( <> - - className="max-w-lg" - disabled={!isCreating} - onSelect={(value) => - setValue((fieldNamePrefix + 'action') as 'action', value) - } - selected={watch((fieldNamePrefix + 'action') as 'action')} - tabs={[ - { - label: t('button.veto'), - value: 'veto', - }, - { - label: t('button.earlyExecute'), - value: 'earlyExecute', - }, - ]} - /> - {!isCreating || (isCreating && daoVetoableProposals.errored) ? ( <> - {isCreating ? ( + {isCreating && ( ) => @@ -182,11 +183,14 @@ export const VetoOrEarlyExecuteDaoProposalComponent: ActionComponent< required value={manualProposalId} /> - ) : chainId && coreAddress && selectedProposalModule ? ( + )} + + {chainId && coreAddress && selectedProposalModule ? ( ) : ( - + !isCreating && ( +

+ {t('error.unexpectedError')} +

+ ) )}
@@ -268,6 +276,7 @@ export const VetoOrEarlyExecuteDaoProposalComponent: ActionComponent< chainId={chainId} coreAddress={coreAddress} isPreProposeProposal={false} + openInNewTab proposalId={`${selectedProposalModule.prefix}${selectedProposal.id}`} proposalViewUrl="" /> diff --git a/packages/stateful/actions/core/actions/VetoProposal/README.md b/packages/stateful/actions/core/actions/VetoProposal/README.md new file mode 100644 index 000000000..a41ced151 --- /dev/null +++ b/packages/stateful/actions/core/actions/VetoProposal/README.md @@ -0,0 +1,23 @@ +# VetoProposal + +Veto a proposal in a DAO. + +## 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 + +`vetoProposal` + +### Data format + +```json +{ + "chainId": "", + "coreAddress": "", + "proposalModuleAddress": "", + "proposalNumber": +} +``` diff --git a/packages/stateful/actions/core/actions/VetoProposal/index.tsx b/packages/stateful/actions/core/actions/VetoProposal/index.tsx new file mode 100644 index 000000000..7c0e3a14d --- /dev/null +++ b/packages/stateful/actions/core/actions/VetoProposal/index.tsx @@ -0,0 +1,260 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' +import { useFormContext } from 'react-hook-form' + +import { contractQueries } from '@dao-dao/state/query' +import { + ActionBase, + ThumbDownEmoji, + useActionOptions, + useCachedLoadingWithError, +} from '@dao-dao/stateless' +import { UnifiedCosmosMsg } from '@dao-dao/types' +import { + ActionComponent, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { + getChainAddressForActionOptions, + makeCw1WhitelistExecuteMessage, + makeExecuteSmartContractMessage, + maybeMakePolytoneExecuteMessages, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { getProposalModule } from '../../../../clients' +import { + AddressInput, + EntityDisplay, + ProposalLine, +} from '../../../../components' +import { useQueryLoadingDataWithError } from '../../../../hooks' +import { daoQueries } from '../../../../queries/dao' +import { daosWithVetoableProposalsSelector } from '../../../../recoil' +import { + VetoProposalComponent as StatelessVetoProposalComponent, + VetoProposalData, +} from './Component' + +const Component: ActionComponent = (props) => { + const { isCreating, fieldNamePrefix } = props + const { + chain: { chain_id: daoChainId }, + address, + } = useActionOptions() + const { watch, setValue } = useFormContext() + + const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') + const coreAddress = watch( + (props.fieldNamePrefix + 'coreAddress') as 'coreAddress' + ) + const proposalId = watch( + (props.fieldNamePrefix + 'proposalId') as 'proposalId' + ) + + const daoVetoableProposals = useCachedLoadingWithError( + daosWithVetoableProposalsSelector({ + chainId: daoChainId, + coreAddress: address, + // Include even those not registered in the DAO's list. + includeAll: true, + }) + ) + + // If no DAO selected, autoselect first one. + useEffect(() => { + if ( + !isCreating || + (chainId && coreAddress) || + daoVetoableProposals.loading || + daoVetoableProposals.errored || + daoVetoableProposals.data.length === 0 + ) { + return + } + + setValue( + (fieldNamePrefix + 'chainId') as 'chainId', + daoVetoableProposals.data[0].chainId + ) + setValue( + (fieldNamePrefix + 'coreAddress') as 'coreAddress', + daoVetoableProposals.data[0].dao + ) + }, [ + chainId, + coreAddress, + daoVetoableProposals, + fieldNamePrefix, + isCreating, + setValue, + ]) + + const queryClient = useQueryClient() + const selectedDaoInfo = useQueryLoadingDataWithError( + daoQueries.info( + queryClient, + chainId && coreAddress + ? { + chainId, + coreAddress, + } + : undefined + ) + ) + + // Select first proposal once loaded if nothing selected. + useEffect(() => { + if ( + isCreating && + !daoVetoableProposals.loading && + !daoVetoableProposals.errored && + !proposalId && + daoVetoableProposals.data.length > 0 + ) { + setValue( + (fieldNamePrefix + 'chainId') as 'chainId', + daoVetoableProposals.data[0].chainId + ) + setValue( + (fieldNamePrefix + 'coreAddress') as 'coreAddress', + daoVetoableProposals.data[0].dao + ) + setValue( + (fieldNamePrefix + 'proposalModuleAddress') as 'proposalModuleAddress', + daoVetoableProposals.data[0].proposalsWithModule[0].proposalModule + .address + ) + setValue( + (fieldNamePrefix + 'proposalId') as 'proposalId', + daoVetoableProposals.data[0].proposalsWithModule[0].proposals[0].id + ) + } + }, [isCreating, proposalId, setValue, fieldNamePrefix, daoVetoableProposals]) + + return ( + + ) +} + +export class VetoProposalAction extends ActionBase { + public readonly key = ActionKey.VetoProposal + public readonly Component = Component + + constructor(options: ActionOptions) { + super(options, { + Icon: ThumbDownEmoji, + label: options.t('title.vetoProposal'), + description: options.t('info.vetoProposalDescription'), + }) + + this.defaults = { + chainId: options.chain.chain_id, + coreAddress: '', + proposalModuleAddress: '', + proposalId: -1, + } + } + + async encode({ + chainId, + proposalModuleAddress, + proposalId, + }: VetoProposalData): Promise { + const sender = getChainAddressForActionOptions(this.options, chainId) + if (!sender) { + throw new Error('No sender found for chain') + } + + const proposalModule = await getProposalModule({ + queryClient: this.options.queryClient, + chainId, + address: proposalModuleAddress, + }) + + const { proposal } = await proposalModule.getProposal({ + proposalId, + }) + + const isCw1Whitelist = proposal.veto + ? await this.options.queryClient.fetchQuery( + contractQueries.isCw1Whitelist(this.options.queryClient, { + chainId, + address: proposal.veto.vetoer, + }) + ) + : false + + const msg = makeExecuteSmartContractMessage({ + chainId, + sender: isCw1Whitelist ? proposal.veto.vetoer : sender, + contractAddress: proposalModuleAddress, + msg: { + veto: { + proposal_id: proposalId, + }, + }, + }) + + return maybeMakePolytoneExecuteMessages( + this.options.chain.chain_id, + chainId, + isCw1Whitelist + ? makeCw1WhitelistExecuteMessage({ + chainId, + sender, + cw1WhitelistContract: proposal.veto.vetoer, + msg, + }) + : msg + ) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return objectMatchesStructure(decodedMessage.wasm.execute.msg, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + veto: { + proposal_id: {}, + }, + }, + }, + }, + }) + } + + async decode([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): Promise { + const proposalModule = await getProposalModule({ + queryClient: this.options.queryClient, + chainId, + address: decodedMessage.wasm.execute.contract_addr, + }) + + return { + chainId, + coreAddress: proposalModule.dao.coreAddress, + proposalModuleAddress: decodedMessage.wasm.execute.contract_addr, + proposalId: decodedMessage.wasm.execute.msg.veto.proposal_id, + } + } +} diff --git a/packages/stateful/actions/core/actions/WithdrawFromRebalancer/index.tsx b/packages/stateful/actions/core/actions/WithdrawFromRebalancer/index.tsx new file mode 100644 index 000000000..b00840b3d --- /dev/null +++ b/packages/stateful/actions/core/actions/WithdrawFromRebalancer/index.tsx @@ -0,0 +1,71 @@ +import { DownArrowEmoji } from '@dao-dao/stateless' +import { AccountType, ChainId, ValenceAccount } from '@dao-dao/types' +import { + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, +} from '@dao-dao/types/actions' +import { getAccount } from '@dao-dao/utils' + +import { SpendAction } from '../../actions/Spend' + +export class WithdrawFromRebalancerAction extends SpendAction { + public readonly key = ActionKey.WithdrawFromRebalancer + + private valenceAccount: ValenceAccount + + constructor(options: ActionOptions) { + super(options) + + // Override Spend metadata. + this._metadata = { + Icon: DownArrowEmoji, + label: options.t('title.withdrawFromRebalancer'), + description: options.t('info.withdrawFromRebalancerDescription'), + } + + const valenceAccount = getAccount({ + accounts: options.context.accounts, + chainId: ChainId.NeutronMainnet, + types: [AccountType.Valence], + }) as ValenceAccount + if (!valenceAccount) { + throw new Error(options.t('error.noValenceAccount')) + } + + this.valenceAccount = valenceAccount + + const SpendComponent = this.Component + this.Component = function WithdrawFromRebalancerActionComponent(props) { + return + } + } + + async setup() { + await super.setup() + + this.defaults = { + ...this.defaults, + fromChainId: this.valenceAccount.chainId, + from: this.valenceAccount.address, + toChainId: this.options.chain.chain_id, + to: this.options.address, + } + } + + async match(messages: ProcessedMessage[]): Promise { + const match = await super.match(messages) + if (!match) { + return false + } + + const decoded = await this.decode(messages) + return ( + decoded.fromChainId === this.valenceAccount.chainId && + decoded.from === this.valenceAccount.address && + decoded.toChainId === this.options.chain.chain_id && + decoded.to === this.options.address + ) + } +} diff --git a/packages/stateful/actions/core/actions/index.ts b/packages/stateful/actions/core/actions/index.ts new file mode 100644 index 000000000..e8e455e3f --- /dev/null +++ b/packages/stateful/actions/core/actions/index.ts @@ -0,0 +1,58 @@ +export * from './AcceptSubDao' +export * from './AuthzExec' +export * from './AuthzGrantRevoke' +export * from './BecomeSubDao' +export * from './BulkImport' +export * from './BurnNft' +export * from './CommunityPoolDeposit' +export * from './CommunityPoolSpend' +export * from './ConfigureRebalancer' +export * from './ConfigureVestingPayments' +export * from './CreateCrossChainAccount' +export * from './CreateDao' +export * from './CreateIca' +export * from './CreateNftCollection' +export * from './CreateValenceAccount' +export * from './CrossChainExecute' +export * from './Custom' +export * from './DaoAdminExec' +export * from './EnableMultipleChoice' +export * from './EnableRetroactiveCompensation' +export * from './Execute' +export * from './ExecuteProposal' +export * from './FeeShare' +export * from './FundRebalancer' +export * from './GovernanceDeposit' +export * from './GovernanceProposal' +export * from './GovernanceVote' +export * from './HideIca' +export * from './IcaExecute' +export * from './Instantiate' +export * from './Instantiate2' +export * from './ManageCw20' +export * from './ManageCw721' +export * from './ManageStaking' +export * from './ManageStorageItems' +export * from './ManageSubDaoPause' +export * from './ManageSubDaos' +export * from './ManageVesting' +export * from './ManageVetoableDaos' +export * from './ManageWidgets' +export * from './Migrate' +export * from './MintNft' +export * from './NeutronOverruleSubDaoProposal' +export * from './PauseRebalancer' +export * from './ResumeRebalancer' +export * from './SetUpApprover' +export * from './Spend' +export * from './TransferNft' +export * from './UpdateAdmin' +export * from './UpdateInfo' +export * from './UpdatePreProposeConfig' +export * from './UpdateProposalConfig' +export * from './UpgradeV1ToV2' +export * from './UploadCode' +export * from './ValidatorActions' +export * from './VetoProposal' +export * from './WithdrawFromRebalancer' +export * from './token_swap' diff --git a/packages/stateful/actions/core/treasury/token_swap/PerformTokenSwap/Component.tsx b/packages/stateful/actions/core/actions/token_swap/PerformTokenSwap/Component.tsx similarity index 96% rename from packages/stateful/actions/core/treasury/token_swap/PerformTokenSwap/Component.tsx rename to packages/stateful/actions/core/actions/token_swap/PerformTokenSwap/Component.tsx index 2ee05ad1c..1a828a8be 100644 --- a/packages/stateful/actions/core/treasury/token_swap/PerformTokenSwap/Component.tsx +++ b/packages/stateful/actions/core/actions/token_swap/PerformTokenSwap/Component.tsx @@ -1,10 +1,10 @@ import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' +import { useActionOptions } from '@dao-dao/stateless' import { ActionComponent } from '@dao-dao/types' import { useTokenSwapStatusInfoForContract } from '../../../../../hooks/useTokenSwapStatusInfoForContract' -import { useActionOptions } from '../../../../react' import { ExistingTokenSwap } from '../stateless/ExistingTokenSwap' export const PerformTokenSwapComponent: ActionComponent = (props) => { diff --git a/packages/stateful/actions/core/treasury/token_swap/PerformTokenSwap/README.md b/packages/stateful/actions/core/actions/token_swap/PerformTokenSwap/README.md similarity index 100% rename from packages/stateful/actions/core/treasury/token_swap/PerformTokenSwap/README.md rename to packages/stateful/actions/core/actions/token_swap/PerformTokenSwap/README.md diff --git a/packages/stateful/actions/core/treasury/token_swap/PerformTokenSwap/index.tsx b/packages/stateful/actions/core/actions/token_swap/PerformTokenSwap/index.tsx similarity index 51% rename from packages/stateful/actions/core/treasury/token_swap/PerformTokenSwap/index.tsx rename to packages/stateful/actions/core/actions/token_swap/PerformTokenSwap/index.tsx index 089963de8..b071cda97 100644 --- a/packages/stateful/actions/core/treasury/token_swap/PerformTokenSwap/index.tsx +++ b/packages/stateful/actions/core/actions/token_swap/PerformTokenSwap/index.tsx @@ -1,22 +1,24 @@ import { coins } from '@cosmjs/amino' -import { useCallback, useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' +import { contractQueries } from '@dao-dao/state/query' import { + ActionBase, HandshakeEmoji, InputErrorMessage, Loader, SegmentedControls, } from '@dao-dao/stateless' +import { UnifiedCosmosMsg } from '@dao-dao/types' import { ActionChainContextType, ActionComponent, ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, + ActionMatch, + ActionOptions, + ProcessedMessage, } from '@dao-dao/types/actions' import { ContractName, @@ -28,7 +30,6 @@ import { } from '@dao-dao/utils' import { SuspenseLoader } from '../../../../../components' -import { useMsgExecutesContract } from '../../../../hooks' import { ChooseExistingTokenSwap } from '../stateful/ChooseExistingTokenSwap' import { InstantiateTokenSwap } from '../stateful/InstantiateTokenSwap' import { PerformTokenSwapData } from '../types' @@ -49,16 +50,6 @@ import { PerformTokenSwapComponent } from './Component' // action accordingly. const CW20_SEND_MSG_KEY = 'dao_dao_initiate_token_swap' -const useDefaults: UseDefaults = () => ({ - contractChosen: false, - tokenSwapContractAddress: undefined, - - // Defaults set once data is fetched in InstantiateTokenSwap since we default - // to specific token info. - selfParty: undefined, - counterparty: undefined, -}) - const Component: ActionComponent = (props) => { const { t } = useTranslation() const { watch, setValue, register } = useFormContext() @@ -132,142 +123,157 @@ const Component: ActionComponent = (props) => { ) } -const useTransformToCosmos: UseTransformToCosmos = () => { - const { t } = useTranslation() +export class PerformTokenSwapAction extends ActionBase { + public readonly key = ActionKey.PerformTokenSwap + public readonly Component = Component + + protected _defaults: PerformTokenSwapData = { + contractChosen: false, + } + + constructor(options: ActionOptions) { + // Check we're on a supported chain. Code IDs are needed to instantiate a swap. + if (options.chainContext.type !== ActionChainContextType.Supported) { + throw new Error('Unsupported chain') + } + + super(options, { + Icon: HandshakeEmoji, + label: options.t('title.tokenSwap'), + description: options.t('info.tokenSwapDescription'), + }) + } - return useCallback( - ({ tokenSwapContractAddress, selfParty }: PerformTokenSwapData) => { - // Should never happen if form validation is working correctly. - if (!tokenSwapContractAddress || !selfParty) { - throw new Error(t('error.loadingData')) - } + encode({ + tokenSwapContractAddress, + selfParty, + }: PerformTokenSwapData): UnifiedCosmosMsg { + // Should never happen if form validation is working correctly. + if (!tokenSwapContractAddress || !selfParty) { + throw new Error(this.options.t('error.loadingData')) + } - // Convert amount to micro amount. - const amount = convertDenomToMicroDenomStringWithDecimals( - selfParty.amount, - selfParty.decimals - ) + // Convert amount to micro amount. + const amount = convertDenomToMicroDenomStringWithDecimals( + selfParty.amount, + selfParty.decimals + ) - return selfParty.type === 'cw20' - ? makeWasmMessage({ - wasm: { - execute: { - // Execute CW20 send message. - contract_addr: selfParty.denomOrAddress, - funds: [], - msg: { - send: { - amount, - contract: tokenSwapContractAddress, - msg: encodeJsonToBase64({ - // Use common key to identify CW20s being sent to token - // swaps from this DAO DAO action. - [CW20_SEND_MSG_KEY]: {}, - }), - }, + return selfParty.type === 'cw20' + ? makeWasmMessage({ + wasm: { + execute: { + // Execute CW20 send message. + contract_addr: selfParty.denomOrAddress, + funds: [], + msg: { + send: { + amount, + contract: tokenSwapContractAddress, + msg: encodeJsonToBase64({ + // Use common key to identify CW20s being sent to token + // swaps from this DAO DAO action. + [CW20_SEND_MSG_KEY]: {}, + }), }, }, }, - }) - : makeWasmMessage({ - wasm: { - execute: { - contract_addr: tokenSwapContractAddress, - funds: coins(amount, selfParty.denomOrAddress), - msg: { - fund: {}, - }, + }, + }) + : makeWasmMessage({ + wasm: { + execute: { + contract_addr: tokenSwapContractAddress, + funds: coins(amount, selfParty.denomOrAddress), + msg: { + fund: {}, }, }, - }) - }, - [t] - ) -} + }, + }) + } -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - const isTokenSwapExecute = useMsgExecutesContract( - msg, - ContractName.CwTokenSwap, + async match([ { - fund: {}, - } - ) - - // Native - if ( - isTokenSwapExecute && - Array.isArray(msg.wasm.execute.funds) && - msg.wasm.execute.funds.length === 1 - ) { - return { - match: true, - data: { - contractChosen: true, - tokenSwapContractAddress: msg.wasm.execute.contract_addr, - // Only used during instantiation. - selfParty: undefined, - counterparty: undefined, - }, - } + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): Promise { + return ( + // Native + (objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + fund: {}, + }, + }, + }, + }) && + (await this.options.queryClient.fetchQuery( + contractQueries.isContract(this.options.queryClient, { + chainId, + address: decodedMessage.wasm.execute.contract_addr, + nameOrNames: ContractName.CwTokenSwap, + }) + ))) || + // CW20 + (objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + send: { + amount: {}, + contract: {}, + msg: {}, + }, + }, + }, + }, + }) && + // Use common key to identify CW20s being sent to token swaps from this + // DAO DAO action. + CW20_SEND_MSG_KEY in + decodeJsonFromBase64( + decodedMessage.wasm.execute.msg.send.msg, + true + ) && + (await this.options.queryClient.fetchQuery( + contractQueries.isContract(this.options.queryClient, { + chainId, + address: decodedMessage.wasm.execute.msg.send.contract, + nameOrNames: ContractName.CwTokenSwap, + }) + ))) + ) } - // CW20 - if ( - objectMatchesStructure(msg, { + decode([{ decodedMessage }]: ProcessedMessage[]): PerformTokenSwapData { + return objectMatchesStructure(decodedMessage, { wasm: { execute: { contract_addr: {}, funds: {}, msg: { - send: { - amount: {}, - contract: {}, - msg: {}, - }, + fund: {}, }, }, }, - }) && - // Use common key to identify CW20s being sent to token swaps from this - // DAO DAO action. - CW20_SEND_MSG_KEY in - decodeJsonFromBase64(msg.wasm.execute.msg.send.msg, true) - ) { - return { - match: true, - data: { - contractChosen: true, - tokenSwapContractAddress: msg.wasm.execute.msg.send.contract, - // Only used during instantiation. - selfParty: undefined, - counterparty: undefined, - }, - } - } - - return { match: false } -} - -export const makePerformTokenSwapAction: ActionMaker = ({ - t, - chainContext, -}) => { - // Check we're on a supported chain. Code IDs needed to instantiate a swap. - if (chainContext.type !== ActionChainContextType.Supported) { - return null - } - - return { - key: ActionKey.PerformTokenSwap, - Icon: HandshakeEmoji, - label: t('title.tokenSwap'), - description: t('info.tokenSwapDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, + }) + ? // Native + { + contractChosen: true, + tokenSwapContractAddress: decodedMessage.wasm.execute.contract_addr, + } + : // CW20 + { + contractChosen: true, + tokenSwapContractAddress: + decodedMessage.wasm.execute.msg.send.contract, + } } } diff --git a/packages/stateful/actions/core/treasury/token_swap/WithdrawTokenSwap/Component.tsx b/packages/stateful/actions/core/actions/token_swap/WithdrawTokenSwap/Component.tsx similarity index 96% rename from packages/stateful/actions/core/treasury/token_swap/WithdrawTokenSwap/Component.tsx rename to packages/stateful/actions/core/actions/token_swap/WithdrawTokenSwap/Component.tsx index f1a475241..b29fd1521 100644 --- a/packages/stateful/actions/core/treasury/token_swap/WithdrawTokenSwap/Component.tsx +++ b/packages/stateful/actions/core/actions/token_swap/WithdrawTokenSwap/Component.tsx @@ -1,10 +1,10 @@ import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' +import { useActionOptions } from '@dao-dao/stateless' import { ActionComponent } from '@dao-dao/types' import { useTokenSwapStatusInfoForContract } from '../../../../../hooks/useTokenSwapStatusInfoForContract' -import { useActionOptions } from '../../../../react' import { ExistingTokenSwap } from '../stateless/ExistingTokenSwap' export const WithdrawTokenSwap: ActionComponent = (props) => { diff --git a/packages/stateful/actions/core/treasury/token_swap/WithdrawTokenSwap/README.md b/packages/stateful/actions/core/actions/token_swap/WithdrawTokenSwap/README.md similarity index 100% rename from packages/stateful/actions/core/treasury/token_swap/WithdrawTokenSwap/README.md rename to packages/stateful/actions/core/actions/token_swap/WithdrawTokenSwap/README.md diff --git a/packages/stateful/actions/core/treasury/token_swap/WithdrawTokenSwap/index.tsx b/packages/stateful/actions/core/actions/token_swap/WithdrawTokenSwap/index.tsx similarity index 55% rename from packages/stateful/actions/core/treasury/token_swap/WithdrawTokenSwap/index.tsx rename to packages/stateful/actions/core/actions/token_swap/WithdrawTokenSwap/index.tsx index ffec42c07..07a53ce5b 100644 --- a/packages/stateful/actions/core/treasury/token_swap/WithdrawTokenSwap/index.tsx +++ b/packages/stateful/actions/core/actions/token_swap/WithdrawTokenSwap/index.tsx @@ -1,20 +1,29 @@ -import { useCallback, useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' -import { BrokenHeartEmoji, InputErrorMessage, Loader } from '@dao-dao/stateless' +import { contractQueries } from '@dao-dao/state/query' +import { + ActionBase, + BrokenHeartEmoji, + InputErrorMessage, + Loader, +} from '@dao-dao/stateless' +import { UnifiedCosmosMsg } from '@dao-dao/types' import { ActionComponent, ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, + ActionMatch, + ActionOptions, + ProcessedMessage, } from '@dao-dao/types/actions' -import { ContractName, makeWasmMessage } from '@dao-dao/utils' +import { + ContractName, + makeWasmMessage, + objectMatchesStructure, +} from '@dao-dao/utils' import { SuspenseLoader } from '../../../../../components' -import { useMsgExecutesContract } from '../../../../hooks' import { ChooseExistingTokenSwap } from '../stateful/ChooseExistingTokenSwap' import { WithdrawTokenSwap } from './Component' @@ -27,11 +36,6 @@ export interface WithdrawTokenSwapData { tokenSwapContractAddress?: string } -const useDefaults: UseDefaults = () => ({ - contractChosen: false, - tokenSwapContractAddress: undefined, -}) - const Component: ActionComponent = ( props ) => { @@ -80,62 +84,70 @@ const Component: ActionComponent = ( ) } -export const makeWithdrawTokenSwapAction: ActionMaker< - WithdrawTokenSwapData -> = ({ t }) => { - const useTransformToCosmos: UseTransformToCosmos< - WithdrawTokenSwapData - > = () => - useCallback(({ tokenSwapContractAddress }: WithdrawTokenSwapData) => { - if (!tokenSwapContractAddress) { - throw new Error(t('error.loadingData')) - } +export class WithdrawTokenSwapAction extends ActionBase { + public readonly key = ActionKey.WithdrawTokenSwap + public readonly Component = Component + + protected _defaults: WithdrawTokenSwapData = { + contractChosen: false, + } + + constructor(options: ActionOptions) { + super(options, { + Icon: BrokenHeartEmoji, + label: options.t('title.withdrawTokenSwap'), + description: options.t('info.withdrawTokenSwapDescription'), + }) + } - return makeWasmMessage({ + encode({ + tokenSwapContractAddress, + }: WithdrawTokenSwapData): UnifiedCosmosMsg { + return makeWasmMessage({ + wasm: { + execute: { + contract_addr: tokenSwapContractAddress, + funds: [], + msg: { + withdraw: {}, + }, + }, + }, + }) + } + + async match([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): Promise { + return ( + objectMatchesStructure(decodedMessage, { wasm: { execute: { - contract_addr: tokenSwapContractAddress, - funds: [], + contract_addr: {}, + funds: {}, msg: { withdraw: {}, }, }, }, - }) - }, []) - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - const isTokenSwapExecute = useMsgExecutesContract( - msg, - ContractName.CwTokenSwap, - { - withdraw: {}, - } + }) && + (await this.options.queryClient.fetchQuery( + contractQueries.isContract(this.options.queryClient, { + chainId, + address: decodedMessage.wasm.execute.contract_addr, + nameOrNames: ContractName.CwTokenSwap, + }) + )) ) - - if (isTokenSwapExecute) { - return { - match: true, - data: { - contractChosen: true, - tokenSwapContractAddress: msg.wasm.execute.contract_addr, - }, - } - } - - return { match: false } } - return { - key: ActionKey.WithdrawTokenSwap, - Icon: BrokenHeartEmoji, - label: t('title.withdrawTokenSwap'), - description: t('info.withdrawTokenSwapDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, + decode([{ decodedMessage }]: ProcessedMessage[]): WithdrawTokenSwapData { + return { + contractChosen: true, + tokenSwapContractAddress: decodedMessage.wasm.execute.contract_addr, + } } } diff --git a/packages/stateful/actions/core/treasury/token_swap/index.ts b/packages/stateful/actions/core/actions/token_swap/index.ts similarity index 100% rename from packages/stateful/actions/core/treasury/token_swap/index.ts rename to packages/stateful/actions/core/actions/token_swap/index.ts diff --git a/packages/stateful/actions/core/treasury/token_swap/stateful/ChooseExistingTokenSwap.tsx b/packages/stateful/actions/core/actions/token_swap/stateful/ChooseExistingTokenSwap.tsx similarity index 99% rename from packages/stateful/actions/core/treasury/token_swap/stateful/ChooseExistingTokenSwap.tsx rename to packages/stateful/actions/core/actions/token_swap/stateful/ChooseExistingTokenSwap.tsx index 98f97da03..5dc19f560 100644 --- a/packages/stateful/actions/core/treasury/token_swap/stateful/ChooseExistingTokenSwap.tsx +++ b/packages/stateful/actions/core/actions/token_swap/stateful/ChooseExistingTokenSwap.tsx @@ -7,6 +7,7 @@ import { CwTokenSwapSelectors, genericTokenSelector, } from '@dao-dao/state/recoil' +import { useActionOptions } from '@dao-dao/stateless' import { ActionComponent, TokenType } from '@dao-dao/types' import { convertMicroDenomToDenomWithDecimals, @@ -14,7 +15,6 @@ import { processError, } from '@dao-dao/utils' -import { useActionOptions } from '../../../../react' import { ChooseExistingTokenSwap as StatelessChooseExistingTokenSwap } from '../stateless/ChooseExistingTokenSwap' interface ChooseExistingTokenSwapOptions { diff --git a/packages/stateful/actions/core/treasury/token_swap/stateful/InstantiateTokenSwap.tsx b/packages/stateful/actions/core/actions/token_swap/stateful/InstantiateTokenSwap.tsx similarity index 98% rename from packages/stateful/actions/core/treasury/token_swap/stateful/InstantiateTokenSwap.tsx rename to packages/stateful/actions/core/actions/token_swap/stateful/InstantiateTokenSwap.tsx index 5d4afb132..0c8791754 100644 --- a/packages/stateful/actions/core/treasury/token_swap/stateful/InstantiateTokenSwap.tsx +++ b/packages/stateful/actions/core/actions/token_swap/stateful/InstantiateTokenSwap.tsx @@ -6,7 +6,7 @@ import { constSelector, useRecoilValueLoadable } from 'recoil' import { genericTokenBalancesSelector } from '@dao-dao/state' import { DaoDaoCoreSelectors } from '@dao-dao/state/recoil' -import { Loader, useCachedLoading } from '@dao-dao/stateless' +import { Loader, useActionOptions, useCachedLoading } from '@dao-dao/stateless' import { ActionChainContextType, ActionComponent, @@ -27,7 +27,6 @@ import { AddressInput, Trans } from '../../../../../components' import { useEntity } from '../../../../../hooks' import { useWallet } from '../../../../../hooks/useWallet' import { useTokenBalances } from '../../../../hooks/useTokenBalances' -import { useActionOptions } from '../../../../react' import { InstantiateTokenSwap as StatelessInstantiateTokenSwap } from '../stateless/InstantiateTokenSwap' import { InstantiateTokenSwapOptions, PerformTokenSwapData } from '../types' diff --git a/packages/stateful/actions/core/treasury/token_swap/stateless/ChooseExistingTokenSwap.stories.tsx b/packages/stateful/actions/core/actions/token_swap/stateless/ChooseExistingTokenSwap.stories.tsx similarity index 100% rename from packages/stateful/actions/core/treasury/token_swap/stateless/ChooseExistingTokenSwap.stories.tsx rename to packages/stateful/actions/core/actions/token_swap/stateless/ChooseExistingTokenSwap.stories.tsx diff --git a/packages/stateful/actions/core/treasury/token_swap/stateless/ChooseExistingTokenSwap.tsx b/packages/stateful/actions/core/actions/token_swap/stateless/ChooseExistingTokenSwap.tsx similarity index 96% rename from packages/stateful/actions/core/treasury/token_swap/stateless/ChooseExistingTokenSwap.tsx rename to packages/stateful/actions/core/actions/token_swap/stateless/ChooseExistingTokenSwap.tsx index c6a9d58bd..56438893c 100644 --- a/packages/stateful/actions/core/treasury/token_swap/stateless/ChooseExistingTokenSwap.tsx +++ b/packages/stateful/actions/core/actions/token_swap/stateless/ChooseExistingTokenSwap.tsx @@ -6,11 +6,11 @@ import { Button, InputErrorMessage, InputLabel, + useActionOptions, } from '@dao-dao/stateless' import { ActionComponent } from '@dao-dao/types' import { makeValidateAddress, validateRequired } from '@dao-dao/utils' -import { useActionOptions } from '../../../../react' import { ChooseExistingTokenSwapOptions } from '../types' // Displayed when entering an existing token swap. diff --git a/packages/stateful/actions/core/treasury/token_swap/stateless/ExistingTokenSwap.stories.tsx b/packages/stateful/actions/core/actions/token_swap/stateless/ExistingTokenSwap.stories.tsx similarity index 100% rename from packages/stateful/actions/core/treasury/token_swap/stateless/ExistingTokenSwap.stories.tsx rename to packages/stateful/actions/core/actions/token_swap/stateless/ExistingTokenSwap.stories.tsx diff --git a/packages/stateful/actions/core/treasury/token_swap/stateless/ExistingTokenSwap.tsx b/packages/stateful/actions/core/actions/token_swap/stateless/ExistingTokenSwap.tsx similarity index 100% rename from packages/stateful/actions/core/treasury/token_swap/stateless/ExistingTokenSwap.tsx rename to packages/stateful/actions/core/actions/token_swap/stateless/ExistingTokenSwap.tsx diff --git a/packages/stateful/actions/core/treasury/token_swap/stateless/InstantiateTokenSwap.stories.tsx b/packages/stateful/actions/core/actions/token_swap/stateless/InstantiateTokenSwap.stories.tsx similarity index 100% rename from packages/stateful/actions/core/treasury/token_swap/stateless/InstantiateTokenSwap.stories.tsx rename to packages/stateful/actions/core/actions/token_swap/stateless/InstantiateTokenSwap.stories.tsx diff --git a/packages/stateful/actions/core/treasury/token_swap/stateless/InstantiateTokenSwap.tsx b/packages/stateful/actions/core/actions/token_swap/stateless/InstantiateTokenSwap.tsx similarity index 99% rename from packages/stateful/actions/core/treasury/token_swap/stateless/InstantiateTokenSwap.tsx rename to packages/stateful/actions/core/actions/token_swap/stateless/InstantiateTokenSwap.tsx index b7a41c66c..4eebde82b 100644 --- a/packages/stateful/actions/core/treasury/token_swap/stateless/InstantiateTokenSwap.tsx +++ b/packages/stateful/actions/core/actions/token_swap/stateless/InstantiateTokenSwap.tsx @@ -6,6 +6,7 @@ import { InputErrorMessage, InputLabel, TokenInput, + useActionOptions, } from '@dao-dao/stateless' import { ActionComponent } from '@dao-dao/types' import { @@ -15,7 +16,6 @@ import { validateRequired, } from '@dao-dao/utils' -import { useActionOptions } from '../../../../react' import { InstantiateTokenSwapOptions } from '../types' // Form displayed when the user is instantiating a new token swap. diff --git a/packages/stateful/actions/core/treasury/token_swap/types.ts b/packages/stateful/actions/core/actions/token_swap/types.ts similarity index 100% rename from packages/stateful/actions/core/treasury/token_swap/types.ts rename to packages/stateful/actions/core/actions/token_swap/types.ts diff --git a/packages/stateful/actions/core/advanced/BulkImport/Component.tsx b/packages/stateful/actions/core/advanced/BulkImport/Component.tsx deleted file mode 100644 index e5199e05f..000000000 --- a/packages/stateful/actions/core/advanced/BulkImport/Component.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import JSON5 from 'json5' -import merge from 'lodash.merge' -import uniq from 'lodash.uniq' -import { parse as csvToJson } from 'papaparse' -import { ComponentType, useState } from 'react' -import { useTranslation } from 'react-i18next' - -import { - ActionsRenderer, - Button, - ButtonLink, - FileDropInput, - Loader, -} from '@dao-dao/stateless' -import { SuspenseLoaderProps, TransProps } from '@dao-dao/types' -import { - ActionAndData, - ActionComponent, - ActionKey, - LoadedAction, - LoadedActions, -} from '@dao-dao/types/actions' -import { objectMatchesStructure } from '@dao-dao/utils' - -export type BulkImportOptions = { - loadedActions: LoadedActions - SuspenseLoader: ComponentType - Trans: ComponentType -} - -type PendingAction = { - loadedAction: LoadedAction - data: any -} - -export const BulkImportComponent: ActionComponent = ({ - addAction, - remove, - options: { loadedActions, SuspenseLoader, Trans }, -}) => { - const { t } = useTranslation() - - const [error, setError] = useState('') - const [pendingActions, setPendingActions] = useState([]) - - // Show loader if any actions are still loading their defaults. - if (Object.values(loadedActions).some((a) => !a.defaults)) { - return - } - - const onSelect = (file: File) => { - setError('') - - if (file.type !== 'application/json' && file.type !== 'text/csv') { - setError(t('error.invalidFileTypeBulkImport')) - return - } - - // Read contents of the file. - const reader = new FileReader() - reader.readAsText(file) - reader.onload = () => { - if (typeof reader.result !== 'string') { - return - } - - let data - try { - switch (file.type) { - case 'application/json': - data = JSON5.parse(reader.result) - break - case 'text/csv': - const parsedCsv = csvToJson(reader.result, { - header: true, - }).data.filter( - (obj: any) => - objectMatchesStructure(obj, { ACTION: {} }) && - obj.ACTION && - Object.values(ActionKey).includes(obj.ACTION) - ) - - data = { - actions: parsedCsv.map(({ ACTION, ...data }: any) => ({ - key: ACTION, - data, - })), - } - - if (!data.actions.length) { - throw new Error(t('error.invalidImportFormatCsv')) - } - - break - default: - throw new Error(t('error.invalidFileTypeBulkImport')) - } - } catch (err) { - setError(err instanceof Error ? err.message : `${err}`) - return - } - - // Validate data is list of `actions` with `key` present. Some actions - // take no `data`, so `data` is optional. - if ( - !objectMatchesStructure(data, { - actions: {}, - }) || - !Array.isArray(data.actions) || - data.actions.length === 0 || - data.actions.some( - (action: any) => - !objectMatchesStructure(action, { - key: {}, - }) - ) - ) { - setError(t('error.invalidImportFormatJson')) - return - } - - const actions = data.actions as { - key: any - data?: any - }[] - - // Verify the action key of each action is valid. - const invalidActionKeys = uniq( - actions.filter( - ({ key }) => typeof key !== 'string' || !(key in loadedActions) - ) - ) - if (invalidActionKeys.length > 0) { - setError( - t('error.invalidActionKeys', { - keys: invalidActionKeys.join(', '), - }) - ) - return - } - - // Error if any actions failed to load. - const erroredAction = actions.flatMap(({ key }) => - loadedActions[key as keyof typeof loadedActions]!.defaults instanceof - Error - ? [loadedActions[key as keyof typeof loadedActions]!] - : [] - )[0] - if (erroredAction) { - setError( - t('error.actionFailedToLoad', { - action: erroredAction.action.label, - error: erroredAction.defaults.message, - }) - ) - return - } - - setPendingActions( - actions.map(({ key, data }): PendingAction => { - // Existence validated above. - const loadedAction = loadedActions[key as keyof typeof loadedActions]! - - return { - loadedAction, - // Use the action's defaults as a base, and then merge in the - // imported data, overriding any defaults. If data is undefined, - // then the action's defaults will be used. - data: merge({}, loadedAction.defaults, data), - } - }) - ) - } - reader.onerror = () => { - console.error(reader.error) - setError(reader.error?.message ?? t('error.loadingData')) - } - } - - const importPending = () => { - // Add all pending actions to the form. - pendingActions.forEach( - ({ - loadedAction: { - action: { key }, - }, - data, - }) => - addAction?.({ - actionKey: key, - data, - }) - ) - // Remove this action from the form. - remove?.() - } - - return pendingActions.length > 0 ? ( - <> -

{t('info.reviewActionImportData')}

- - ({ - action, - data, - }) - )} - hideCopyLink - /> - -
- - - -
- - ) : ( - <> -

- - Choose a JSON or CSV file below that matches the format described in{' '} - - this guide - - . - -

- - - - {error &&

{error}

} - - ) -} diff --git a/packages/stateful/actions/core/advanced/BulkImport/index.tsx b/packages/stateful/actions/core/advanced/BulkImport/index.tsx deleted file mode 100644 index fa7f97c6d..000000000 --- a/packages/stateful/actions/core/advanced/BulkImport/index.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { FileFolderEmoji } from '@dao-dao/stateless' -import { - ActionComponent, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' - -import { SuspenseLoader } from '../../../../components/SuspenseLoader' -import { Trans } from '../../../../components/Trans' -import { useLoadedActionsAndCategories } from '../../../react/context' -import { BulkImportComponent } from './Component' - -const useDefaults: UseDefaults = () => ({}) -const useTransformToCosmos: UseTransformToCosmos = () => () => undefined -const useDecodedCosmosMsg: UseDecodedCosmosMsg = () => ({ match: false }) - -const Component: ActionComponent = (props) => ( - -) - -// This action is not intended to output any messages. It is just an interface -// that can add other actions. -export const makeBulkImportAction: ActionMaker = ({ t }) => ({ - key: ActionKey.BulkImport, - Icon: FileFolderEmoji, - label: t('title.bulkImportActions'), - description: t('info.bulkImportActionsDescription'), - notReusable: true, - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, -}) diff --git a/packages/stateful/actions/core/advanced/CreateIca/index.tsx b/packages/stateful/actions/core/advanced/CreateIca/index.tsx deleted file mode 100644 index f37e5d19f..000000000 --- a/packages/stateful/actions/core/advanced/CreateIca/index.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import { useCallback, useEffect } from 'react' -import { useFormContext } from 'react-hook-form' -import { useTranslation } from 'react-i18next' - -import { - chainSupportsIcaControllerSelector, - chainSupportsIcaHostSelector, - icaRemoteAddressSelector, -} from '@dao-dao/state/recoil' -import { ChainEmoji, useCachedLoadingWithError } from '@dao-dao/stateless' -import { - ActionComponent, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseHideFromPicker, - UseTransformToCosmos, - makeStargateMessage, -} from '@dao-dao/types' -import { MsgRegisterInterchainAccount } from '@dao-dao/types/protobuf/codegen/ibc/applications/interchain_accounts/controller/v1/tx' -import { Metadata } from '@dao-dao/types/protobuf/codegen/ibc/applications/interchain_accounts/v1/metadata' -import { - getChainForChainName, - getDisplayNameForChainId, - getIbcTransferInfoBetweenChains, - getIbcTransferInfoFromConnection, - isDecodedStargateMsg, -} from '@dao-dao/utils' - -import { useActionOptions } from '../../../react' -import { CreateIcaComponent, CreateIcaData } from './Component' - -const useDefaults: UseDefaults = () => ({ - chainId: '', -}) - -const Component: ActionComponent = (props) => { - const { t } = useTranslation() - const { - address, - chain: { chain_id: srcChainId }, - } = useActionOptions() - - const { watch, setError, clearErrors } = useFormContext() - const destChainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') - - const createdAddressLoading = useCachedLoadingWithError( - icaRemoteAddressSelector({ - address, - srcChainId, - destChainId, - }) - ) - const icaHostSupported = useCachedLoadingWithError( - chainSupportsIcaHostSelector({ - chainId: destChainId, - }) - ) - - // If ICA account already exists or ICA host not enabled for this chain during - // creation, add error preventing submission. - useEffect(() => { - if ( - destChainId && - !icaHostSupported.loading && - !icaHostSupported.updating && - (icaHostSupported.errored || !icaHostSupported.data) && - props.isCreating - ) { - setError((props.fieldNamePrefix + 'chainId') as 'chainId', { - type: 'manual', - message: icaHostSupported.errored - ? icaHostSupported.error.message - : t('error.icaHostUnsupported', { - chain: getDisplayNameForChainId(destChainId), - }), - }) - } else if ( - destChainId && - !createdAddressLoading.loading && - !createdAddressLoading.updating && - !createdAddressLoading.errored && - createdAddressLoading.data && - props.isCreating - ) { - setError((props.fieldNamePrefix + 'chainId') as 'chainId', { - type: 'manual', - message: t('error.icaAlreadyExists', { - chain: getDisplayNameForChainId(destChainId), - }), - }) - } else { - clearErrors((props.fieldNamePrefix + 'chainId') as 'chainId') - } - }, [ - clearErrors, - createdAddressLoading, - destChainId, - icaHostSupported, - props.fieldNamePrefix, - props.isCreating, - setError, - t, - ]) - - return ( - - ) -} - -export const makeCreateIcaAction: ActionMaker = ({ - t, - chain: { chain_id: sourceChainId }, - address, -}) => { - const useTransformToCosmos: UseTransformToCosmos = () => - useCallback(({ chainId }) => { - if (!chainId) { - return - } - - const info = getIbcTransferInfoBetweenChains(sourceChainId, chainId) - - return makeStargateMessage({ - stargate: { - typeUrl: MsgRegisterInterchainAccount.typeUrl, - value: MsgRegisterInterchainAccount.fromPartial({ - owner: address, - connectionId: info.sourceChain.connection_id, - version: JSON.stringify( - Metadata.fromPartial({ - version: 'ics27-1', - controllerConnectionId: info.sourceChain.connection_id, - hostConnectionId: info.destinationChain.connection_id, - // Empty when registering a new address. - address: '', - encoding: 'proto3', - txType: 'sdk_multi_msg', - }) - ), - }), - }, - }) - }, []) - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - if ( - !isDecodedStargateMsg(msg) || - msg.stargate.typeUrl !== MsgRegisterInterchainAccount.typeUrl - ) { - return { - match: false, - } - } - - try { - const { connectionId } = msg.stargate.value - const { destinationChain } = getIbcTransferInfoFromConnection( - sourceChainId, - connectionId - ) - - return { - match: true, - data: { - chainId: getChainForChainName(destinationChain.chain_name).chain_id, - }, - } - } catch (err) { - return { - match: false, - } - } - } - - // Hide from picker if chain does not support ICA controller. - const useHideFromPicker: UseHideFromPicker = () => { - const supported = useCachedLoadingWithError( - chainSupportsIcaControllerSelector({ - chainId: sourceChainId, - }) - ) - - return supported.loading || supported.errored || !supported.data - } - - return { - key: ActionKey.CreateIca, - Icon: ChainEmoji, - label: t('title.createIca'), - description: t('info.createIcaDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - useHideFromPicker, - } -} diff --git a/packages/stateful/actions/core/advanced/CrossChainExecute/index.tsx b/packages/stateful/actions/core/advanced/CrossChainExecute/index.tsx deleted file mode 100644 index 61a4be1cb..000000000 --- a/packages/stateful/actions/core/advanced/CrossChainExecute/index.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { useCallback } from 'react' -import { useFormContext } from 'react-hook-form' - -import { - ChainProvider, - DaoSupportedChainPickerInput, - TelescopeEmoji, - useChain, -} from '@dao-dao/stateless' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types' -import { - decodePolytoneExecuteMsg, - getChainAddressForActionOptions, - maybeMakePolytoneExecuteMessage, -} from '@dao-dao/utils' - -import { SuspenseLoader } from '../../../../components' -import { - WalletActionsProvider, - useActionOptions, - useActionsForMatching, - useLoadedActionsAndCategories, -} from '../../../react' -import { - CrossChainExecuteData, - CrossChainExecuteComponent as StatelessCrossChainExecuteComponent, -} from './Component' - -const InnerComponentLoading: ActionComponent = (props) => ( - -) - -const InnerComponent: ActionComponent = (props) => { - const { categories, loadedActions } = useLoadedActionsAndCategories({ - isCreating: props.isCreating, - }) - const actionsForMatching = useActionsForMatching() - - return ( - - ) -} - -const InnerComponentWrapper: ActionComponent = (props) => { - const { chain_id: chainId } = useChain() - - const options = useActionOptions() - const address = getChainAddressForActionOptions(options, chainId) - - return address ? ( - - - - ) : ( - - ) -} - -const Component: ActionComponent = (props) => { - const { - context, - chain: { chain_id: currentChainId }, - } = useActionOptions() - - const { watch } = useFormContext() - const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') - - return ( - <> - {context.type === ActionContextType.Dao && ( - - )} - - {chainId !== currentChainId && ( - // Re-render when chain changes so hooks and state reset. - - - - )} - - ) -} - -export const makeCrossChainExecuteAction: ActionMaker< - CrossChainExecuteData -> = ({ t, context, chain: { chain_id: currentChainId } }) => { - // Only support DAO polytone. - if (context.type !== ActionContextType.Dao) { - return null - } - - const useDefaults: UseDefaults = () => ({ - chainId: currentChainId, - msgs: [], - }) - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - const decodedPolytone = decodePolytoneExecuteMsg(currentChainId, msg, 'any') - - return decodedPolytone.match - ? { - match: true, - data: { - chainId: decodedPolytone.chainId, - msgs: decodedPolytone.cosmosMsgs, - }, - } - : { - match: false, - } - } - - const useTransformToCosmos: UseTransformToCosmos< - CrossChainExecuteData - > = () => - useCallback( - ({ chainId, msgs }) => - currentChainId === chainId - ? undefined - : maybeMakePolytoneExecuteMessage(currentChainId, chainId, msgs), - [] - ) - - return { - key: ActionKey.CrossChainExecute, - Icon: TelescopeEmoji, - label: t('title.crossChainExecute'), - description: t('info.crossChainExecuteDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - // Disallow creation if no accounts created. - hideFromPicker: - Object.values(context.dao.info.polytoneProxies).length === 0, - } -} diff --git a/packages/stateful/actions/core/advanced/Custom/index.tsx b/packages/stateful/actions/core/advanced/Custom/index.tsx deleted file mode 100644 index 327a2c787..000000000 --- a/packages/stateful/actions/core/advanced/Custom/index.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useCallback, useMemo } from 'react' - -import { RobotEmoji } from '@dao-dao/stateless' -import { - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { convertJsonToCWCosmosMsg } from '@dao-dao/utils' - -import { CustomComponent as Component, CustomData } from './Component' - -const useDefaults: UseDefaults = () => ({ - message: '{}', -}) - -const useTransformToCosmos: UseTransformToCosmos = () => - useCallback((data: CustomData) => convertJsonToCWCosmosMsg(data.message), []) - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => - useMemo( - () => ({ - match: true, - data: { - message: JSON.stringify(msg, undefined, 2), - }, - }), - [msg] - ) - -export const makeCustomAction: ActionMaker = ({ t, context }) => ({ - key: ActionKey.Custom, - Icon: RobotEmoji, - label: t('title.custom'), - description: t('info.customActionDescription', { - context: context.type, - }), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, -}) diff --git a/packages/stateful/actions/core/advanced/ManageIcas/README.md b/packages/stateful/actions/core/advanced/ManageIcas/README.md deleted file mode 100644 index e758ee1a2..000000000 --- a/packages/stateful/actions/core/advanced/ManageIcas/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# ManageIcas - -Add Interchain Account to or remove from the treasury. - -## 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 - -`manageIcas` - -### Data format - -```json -{ - "chainId": ", - "register": -} -``` diff --git a/packages/stateful/actions/core/advanced/ManageIcas/index.tsx b/packages/stateful/actions/core/advanced/ManageIcas/index.tsx deleted file mode 100644 index 9742e22db..000000000 --- a/packages/stateful/actions/core/advanced/ManageIcas/index.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useCallback } from 'react' - -import { chainSupportsIcaControllerSelector } from '@dao-dao/state/recoil' -import { ChainEmoji, useCachedLoadingWithError } from '@dao-dao/stateless' -import { Feature } from '@dao-dao/types' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseHideFromPicker, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { - ICA_CHAINS_TX_PREFIX, - getFilteredDaoItemsByPrefix, - makeWasmMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { useActionOptions } from '../../../react' -import { - ManageIcasData, - ManageIcasComponent as StatelessManageIcasComponent, -} from './Component' - -const useDefaults: UseDefaults = () => ({ - chainId: '', - register: true, -}) - -const Component: ActionComponent = (props) => { - const { context } = useActionOptions() - - if (context.type !== ActionContextType.Dao) { - return null - } - - const currentlyEnabled = getFilteredDaoItemsByPrefix( - context.dao.info.items, - ICA_CHAINS_TX_PREFIX - ).map(([key]) => key) - - return ( - - ) -} - -export const makeManageIcasAction: ActionMaker = ({ - t, - address, - chain: { chain_id: sourceChainId }, - context, -}) => { - // Only DAOs. - if (context.type !== ActionContextType.Dao) { - return null - } - - const storageItemValueKey = context.dao.info.supportedFeatures[ - Feature.StorageItemValueKey - ] - ? 'value' - : 'addr' - - const useTransformToCosmos: UseTransformToCosmos = () => - useCallback( - ({ chainId, register }: ManageIcasData) => - makeWasmMessage({ - wasm: { - execute: { - contract_addr: address, - funds: [], - msg: register - ? { - set_item: { - key: ICA_CHAINS_TX_PREFIX + chainId, - [storageItemValueKey]: '1', - }, - } - : { - remove_item: { - key: ICA_CHAINS_TX_PREFIX + chainId, - }, - }, - }, - }, - }), - [] - ) - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - if ( - objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: {}, - }, - }, - }) && - msg.wasm.execute.contract_addr === address && - ('set_item' in msg.wasm.execute.msg || - 'remove_item' in msg.wasm.execute.msg) - ) { - const register = 'set_item' in msg.wasm.execute.msg - const key = - (register - ? msg.wasm.execute.msg.set_item.key - : msg.wasm.execute.msg.remove_item.key) ?? '' - - const chainId = key.replace(ICA_CHAINS_TX_PREFIX, '') - - return key.startsWith(ICA_CHAINS_TX_PREFIX) - ? { - match: true, - data: { - chainId, - register, - }, - } - : { - match: false, - } - } - - return { match: false } - } - - // Hide from picker if chain does not support ICA controller. - const useHideFromPicker: UseHideFromPicker = () => { - const supported = useCachedLoadingWithError( - chainSupportsIcaControllerSelector({ - chainId: sourceChainId, - }) - ) - - return supported.loading || supported.errored || !supported.data - } - - return { - key: ActionKey.ManageIcas, - Icon: ChainEmoji, - label: t('title.manageIcas'), - description: t('info.manageIcasDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - useHideFromPicker, - } -} diff --git a/packages/stateful/actions/core/advanced/index.ts b/packages/stateful/actions/core/advanced/index.ts deleted file mode 100644 index a574d474d..000000000 --- a/packages/stateful/actions/core/advanced/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ActionCategoryKey, ActionCategoryMaker } from '@dao-dao/types' - -import { makeBulkImportAction } from './BulkImport' -import { makeCreateIcaAction } from './CreateIca' -import { makeCrossChainExecuteAction } from './CrossChainExecute' -import { makeCustomAction } from './Custom' -import { makeIcaExecuteAction } from './IcaExecute' -import { makeManageIcasAction } from './ManageIcas' - -export const makeAdvancedActionCategory: ActionCategoryMaker = ({ t }) => ({ - key: ActionCategoryKey.Advanced, - label: t('actionCategory.advancedLabel'), - description: t('actionCategory.advancedDescription'), - actionMakers: [ - makeCustomAction, - makeBulkImportAction, - makeCrossChainExecuteAction, - makeCreateIcaAction, - makeManageIcasAction, - makeIcaExecuteAction, - ], -}) diff --git a/packages/stateful/actions/core/authorizations/AuthzGrantRevoke/index.tsx b/packages/stateful/actions/core/authorizations/AuthzGrantRevoke/index.tsx deleted file mode 100644 index c47c19893..000000000 --- a/packages/stateful/actions/core/authorizations/AuthzGrantRevoke/index.tsx +++ /dev/null @@ -1,472 +0,0 @@ -import { fromUtf8, toUtf8 } from '@cosmjs/encoding' -import JSON5 from 'json5' -import { useCallback } from 'react' -import { useFormContext } from 'react-hook-form' - -import { - ChainProvider, - DaoSupportedChainPickerInput, - KeyEmoji, - Loader, -} from '@dao-dao/stateless' -import { TokenType, makeStargateMessage } from '@dao-dao/types' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { GenericAuthorization } from '@dao-dao/types/protobuf/codegen/cosmos/authz/v1beta1/authz' -import { - MsgGrant, - MsgRevoke, -} from '@dao-dao/types/protobuf/codegen/cosmos/authz/v1beta1/tx' -import { SendAuthorization } from '@dao-dao/types/protobuf/codegen/cosmos/bank/v1beta1/authz' -import { - AcceptedMessageKeysFilter, - AcceptedMessagesFilter, - CombinedLimit, - ContractExecutionAuthorization, - ContractGrant, - ContractMigrationAuthorization, - MaxCallsLimit, -} from '@dao-dao/types/protobuf/codegen/cosmwasm/wasm/v1/authz' -import { Any } from '@dao-dao/types/protobuf/codegen/google/protobuf/any' -import { - convertDenomToMicroDenomStringWithDecimals, - convertMicroDenomToDenomWithDecimals, - decodePolytoneExecuteMsg, - getChainAddressForActionOptions, - isDecodedStargateMsg, - maybeMakePolytoneExecuteMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { AddressInput, SuspenseLoader } from '../../../../components' -import { useQueryTokens } from '../../../../hooks/query' -import { useTokenBalances } from '../../../hooks' -import { useActionOptions } from '../../../react' -import { AuthzGrantRevokeComponent as StatelessAuthzAuthorizationComponent } from './Component' -import { - ACTION_TYPES, - AUTHORIZATION_TYPES, - AuthzGrantRevokeData, - FILTER_TYPES, - LIMIT_TYPES, -} from './types' - -const Component: ActionComponent = (props) => { - const balances = useTokenBalances() - - const { context } = useActionOptions() - const { watch } = useFormContext() - const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') - - return ( - <> - {context.type === ActionContextType.Dao && ( - - )} - - - } - forceFallback={ - // Manually trigger loader. - balances.loading - } - > - - - - - ) -} - -export const makeAuthzGrantRevokeAction: ActionMaker = ( - options -) => { - const { - t, - chain: { chain_id: currentChainId }, - } = options - - const useDefaults: UseDefaults = () => ({ - chainId: currentChainId, - mode: 'grant', - authorizationTypeUrl: AUTHORIZATION_TYPES[0].type.typeUrl, - customTypeUrl: false, - grantee: '', - filterTypeUrl: FILTER_TYPES[0].type.typeUrl, - filterKeys: '', - filterMsgs: '{}', - funds: [], - contract: '', - calls: 10, - limitTypeUrl: LIMIT_TYPES[0].type.typeUrl, - msgTypeUrl: ACTION_TYPES[0].type.typeUrl, - }) - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - let chainId = currentChainId - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - } - - const defaults = useDefaults() as AuthzGrantRevokeData - - const isStargate = isDecodedStargateMsg(msg) - const isGrant = isStargate && msg.stargate.typeUrl === MsgGrant.typeUrl - const isRevoke = isStargate && msg.stargate.typeUrl === MsgRevoke.typeUrl - - const grant = isGrant ? (msg.stargate.value as MsgGrant).grant : undefined - const grantAuthorizationTypeUrl = - grant && - // If not auto-decoded, will be Any. This should be the case for the - // CosmWasm contract authorizations. $typeUrl will be Any which is - // unhelpful. - (grant.authorization?.typeUrl || - // If auto-decoded, such as Generic or Send, this will be set instead. - grant.authorization?.$typeUrl) - - const funds = - grant && grantAuthorizationTypeUrl - ? grantAuthorizationTypeUrl === SendAuthorization.typeUrl - ? (grant.authorization as SendAuthorization).spendLimit - : grantAuthorizationTypeUrl === - ContractExecutionAuthorization.typeUrl || - grantAuthorizationTypeUrl === ContractMigrationAuthorization.typeUrl - ? ( - grant.authorization as - | ContractExecutionAuthorization - | ContractMigrationAuthorization - ).grants[0]?.limit?.amounts - : undefined - : undefined - - const tokens = useQueryTokens( - funds?.map(({ denom }) => ({ - chainId, - type: TokenType.Native, - denomOrAddress: denom, - })) - ) - - if ( - (!isGrant && !isRevoke) || - tokens.loading || - tokens.errored || - !objectMatchesStructure(msg.stargate.value, { - grantee: {}, - granter: {}, - }) - ) { - return { match: false } - } - - if (isGrant) { - const grantMsg = msg.stargate.value as MsgGrant - const authorizationTypeUrl = grantAuthorizationTypeUrl - - // If no authorization type, this is not a match - if (!authorizationTypeUrl || !grant) { - return { match: false } - } - - switch (authorizationTypeUrl) { - case GenericAuthorization.typeUrl: { - const msgTypeUrl = (grant.authorization as GenericAuthorization).msg - return { - match: true, - data: { - ...defaults, - chainId, - mode: 'grant', - authorizationTypeUrl, - customTypeUrl: !ACTION_TYPES.some( - ({ type: { typeUrl } }) => typeUrl === msgTypeUrl - ), - msgTypeUrl, - grantee: grantMsg.grantee, - }, - } - } - - case SendAuthorization.typeUrl: { - const { spendLimit } = grant.authorization as SendAuthorization - - return { - match: true, - data: { - ...defaults, - chainId, - mode: 'grant', - authorizationTypeUrl, - customTypeUrl: false, - grantee: grantMsg.grantee, - funds: - spendLimit.map(({ denom, amount }) => { - const decimals = - tokens.data.find((t) => t.denomOrAddress === denom) - ?.decimals || 0 - return { - denom, - amount: convertMicroDenomToDenomWithDecimals( - amount, - decimals - ), - decimals, - } - }) ?? [], - }, - } - } - - case ContractExecutionAuthorization.typeUrl: - case ContractMigrationAuthorization.typeUrl: { - const grants: ContractGrant[] = ( - grant.authorization as - | ContractExecutionAuthorization - | ContractMigrationAuthorization - ).grants - - if (grants.length !== 1) { - return { match: false } - } - - const { contract, filter, limit } = grants[0] - - // Type guard, should always pass until new types are added. - if ( - !limit?.$typeUrl || - !filter?.$typeUrl || - !LIMIT_TYPES.some( - ({ type: { typeUrl } }) => typeUrl === limit.$typeUrl - ) || - !FILTER_TYPES.some( - ({ type: { typeUrl } }) => typeUrl === filter.$typeUrl - ) - ) { - return { match: false } - } - - const filterMsgs = - filter.$typeUrl === AcceptedMessagesFilter.typeUrl - ? (filter.messages as Uint8Array[]).map((msg) => - JSON.parse(fromUtf8(msg)) - ) - : [] - - return { - match: true, - data: { - ...defaults, - chainId, - mode: 'grant', - authorizationTypeUrl, - customTypeUrl: false, - grantee: msg.stargate.value.grantee, - funds: - limit.amounts?.map(({ denom, amount }) => { - const decimals = - tokens.data.find((t) => t.denomOrAddress === denom) - ?.decimals || 0 - return { - denom, - amount: convertMicroDenomToDenomWithDecimals( - amount, - decimals - ), - decimals, - } - }) ?? [], - contract, - filterTypeUrl: filter.$typeUrl, - filterKeys: - filter.$typeUrl === AcceptedMessageKeysFilter.typeUrl - ? filter.keys.join() - : '', - filterMsgs: JSON.stringify( - filterMsgs.length === 0 - ? {} - : filterMsgs.length === 1 - ? filterMsgs[0] - : filterMsgs, - null, - 2 - ), - limitTypeUrl: limit.$typeUrl, - calls: - limit.$typeUrl === MaxCallsLimit.typeUrl - ? Number(limit.remaining) - : limit.$typeUrl === CombinedLimit.typeUrl - ? Number(limit.callsRemaining) - : 0, - }, - } - } - - default: - return { match: false } - } - } else if (isRevoke) { - const msgTypeUrl = msg.stargate.value.msgTypeUrl - - return { - match: true, - data: { - ...defaults, - chainId, - mode: 'revoke', - customTypeUrl: !ACTION_TYPES.some( - ({ type: { typeUrl } }) => typeUrl === msgTypeUrl - ), - grantee: msg.stargate.value.grantee, - msgTypeUrl, - }, - } - } - - return { match: false } - } - - const useTransformToCosmos: UseTransformToCosmos = () => - useCallback( - ({ - chainId, - mode, - authorizationTypeUrl, - grantee, - msgTypeUrl, - filterKeys, - filterMsgs, - filterTypeUrl, - funds, - contract, - limitTypeUrl, - calls, - }: AuthzGrantRevokeData) => { - const parsedFilterMsgs = JSON5.parse(filterMsgs) - const filter: Any | undefined = FILTER_TYPES.find( - ({ type: { typeUrl } }) => typeUrl === filterTypeUrl - )?.type.toProtoMsg({ - // AcceptedMessageKeysFilter - keys: filterKeys.split(',').map((k) => k.trim()), - // AcceptedMessagesFilter - messages: (Array.isArray(parsedFilterMsgs) - ? parsedFilterMsgs - : [parsedFilterMsgs] - ).map((m: unknown) => toUtf8(JSON.stringify(m))), - }) - - const limit: Any | undefined = LIMIT_TYPES.find( - ({ type: { typeUrl } }) => typeUrl === limitTypeUrl - )?.type.toProtoMsg({ - // MaxCallsLimit - remaining: BigInt(calls), - // CombinedLimit - callsRemaining: BigInt(calls), - // MaxFundsLimit - // CombinedLimit - amounts: funds.map(({ denom, amount, decimals }) => ({ - amount: convertDenomToMicroDenomStringWithDecimals( - amount, - decimals - ), - denom, - })), - }) - - let authorization: Any | undefined - if (mode === 'grant') { - authorization = AUTHORIZATION_TYPES.find( - ({ type: { typeUrl } }) => typeUrl === authorizationTypeUrl - )?.type.toProtoMsg({ - // GenericAuthorization - msg: msgTypeUrl, - // SendAuthorization - spendLimit: funds.map(({ denom, amount, decimals }) => ({ - amount: convertDenomToMicroDenomStringWithDecimals( - amount, - decimals - ), - denom, - })), - allowList: [], - // ContractExecutionAuthorization - // ContractMigrationAuthorization - grants: [ - { - contract, - filter: filter as any, - limit: limit as any, - }, - ], - }) - - if (!authorization) { - throw new Error('Unknown authorization type') - } - } - - // Expiration set to 10 years. - const expiration = new Date() - expiration.setFullYear(expiration.getFullYear() + 10) - // Encoder needs a whole number of seconds. - expiration.setMilliseconds(0) - - return maybeMakePolytoneExecuteMessage( - currentChainId, - chainId, - makeStargateMessage({ - stargate: { - typeUrl: mode === 'grant' ? MsgGrant.typeUrl : MsgRevoke.typeUrl, - value: { - ...(mode === 'grant' && authorization - ? { - grant: { - authorization, - expiration, - }, - } - : { - msgTypeUrl, - }), - grantee, - granter: getChainAddressForActionOptions(options, chainId), - }, - }, - }) - ) - }, - [] - ) - - return { - key: ActionKey.AuthzGrantRevoke, - Icon: KeyEmoji, - label: t('title.authzAuthorization'), - description: t('info.authzAuthorizationDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - } -} diff --git a/packages/stateful/actions/core/authorizations/index.ts b/packages/stateful/actions/core/authorizations/index.ts deleted file mode 100644 index ffa7577c2..000000000 --- a/packages/stateful/actions/core/authorizations/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ActionCategoryKey, ActionCategoryMaker } from '@dao-dao/types' - -import { makeAuthzExecAction } from './AuthzExec' -import { makeAuthzGrantRevokeAction } from './AuthzGrantRevoke' - -export const makeAuthorizationsActionCategory: ActionCategoryMaker = ({ - t, -}) => ({ - key: ActionCategoryKey.Authorizations, - label: t('actionCategory.authorizationsLabel'), - description: t('actionCategory.authorizationsDescription'), - actionMakers: [makeAuthzGrantRevokeAction, makeAuthzExecAction], -}) diff --git a/packages/stateful/actions/core/categories/advanced.ts b/packages/stateful/actions/core/categories/advanced.ts new file mode 100644 index 000000000..8265e7b8a --- /dev/null +++ b/packages/stateful/actions/core/categories/advanced.ts @@ -0,0 +1,19 @@ +import { + ActionCategoryKey, + ActionCategoryMaker, + ActionKey, +} from '@dao-dao/types' + +export const makeAdvancedActionCategory: ActionCategoryMaker = ({ t }) => ({ + key: ActionCategoryKey.Advanced, + label: t('actionCategory.advancedLabel'), + description: t('actionCategory.advancedDescription'), + actionKeys: [ + ActionKey.Custom, + ActionKey.BulkImport, + ActionKey.CrossChainExecute, + ActionKey.CreateIca, + ActionKey.HideIca, + ActionKey.IcaExecute, + ], +}) diff --git a/packages/stateful/actions/core/categories/authorizations.ts b/packages/stateful/actions/core/categories/authorizations.ts new file mode 100644 index 000000000..b81b5be06 --- /dev/null +++ b/packages/stateful/actions/core/categories/authorizations.ts @@ -0,0 +1,14 @@ +import { + ActionCategoryKey, + ActionCategoryMaker, + ActionKey, +} from '@dao-dao/types' + +export const makeAuthorizationsActionCategory: ActionCategoryMaker = ({ + t, +}) => ({ + key: ActionCategoryKey.Authorizations, + label: t('actionCategory.authorizationsLabel'), + description: t('actionCategory.authorizationsDescription'), + actionKeys: [ActionKey.AuthzGrantRevoke, ActionKey.AuthzExec], +}) diff --git a/packages/stateful/actions/core/categories/chain-governance.ts b/packages/stateful/actions/core/categories/chain-governance.ts new file mode 100644 index 000000000..931902bf7 --- /dev/null +++ b/packages/stateful/actions/core/categories/chain-governance.ts @@ -0,0 +1,19 @@ +import { + ActionCategoryKey, + ActionCategoryMaker, + ActionKey, +} from '@dao-dao/types' + +export const makeChainGovernanceActionCategory: ActionCategoryMaker = ({ + t, +}) => ({ + key: ActionCategoryKey.ChainGovernance, + label: t('actionCategory.chainGovernanceLabel'), + description: t('actionCategory.chainGovernanceDescription'), + actionKeys: [ + ActionKey.GovernanceVote, + ActionKey.GovernanceProposal, + ActionKey.GovernanceDeposit, + ActionKey.ValidatorActions, + ], +}) diff --git a/packages/stateful/actions/core/categories/commonly-used.ts b/packages/stateful/actions/core/categories/commonly-used.ts new file mode 100644 index 000000000..b40f4aa0a --- /dev/null +++ b/packages/stateful/actions/core/categories/commonly-used.ts @@ -0,0 +1,23 @@ +import { + ActionCategoryKey, + ActionCategoryMaker, + ActionKey, +} from '@dao-dao/types' + +export const makeCommonlyUsedCategory: ActionCategoryMaker = ({ t }) => ({ + key: ActionCategoryKey.CommonlyUsed, + label: t('actionCategory.commonlyUsedLabel'), + description: t('actionCategory.commonlyUsedDescription'), + actionKeys: [ + ActionKey.UpgradeV1ToV2, + ActionKey.Spend, + ActionKey.ManageStaking, + ActionKey.CreateCrossChainAccount, + ActionKey.ManageVesting, + ActionKey.AuthzGrantRevoke, + ActionKey.GovernanceVote, + ActionKey.Execute, + ActionKey.Instantiate, + ActionKey.ConfigureVestingPayments, + ], +}) diff --git a/packages/stateful/actions/core/dao_appearance/index.ts b/packages/stateful/actions/core/categories/dao-appearance.ts similarity index 70% rename from packages/stateful/actions/core/dao_appearance/index.ts rename to packages/stateful/actions/core/categories/dao-appearance.ts index bcf576d09..ee12645aa 100644 --- a/packages/stateful/actions/core/dao_appearance/index.ts +++ b/packages/stateful/actions/core/categories/dao-appearance.ts @@ -2,11 +2,9 @@ import { ActionCategoryKey, ActionCategoryMaker, ActionContextType, + ActionKey, } from '@dao-dao/types' -import { makeManageWidgetsAction } from './ManageWidgets' -import { makeUpdateInfoAction } from './UpdateInfo' - export const makeDaoAppearanceActionCategory: ActionCategoryMaker = ({ t, context, @@ -17,6 +15,6 @@ export const makeDaoAppearanceActionCategory: ActionCategoryMaker = ({ key: ActionCategoryKey.DaoAppearance, label: t('actionCategory.appearanceLabel'), description: t('actionCategory.appearanceDescription'), - actionMakers: [makeUpdateInfoAction, makeManageWidgetsAction], + actionKeys: [ActionKey.UpdateInfo, ActionKey.ManageWidgets], } : null diff --git a/packages/stateful/actions/core/categories/dao-governance.ts b/packages/stateful/actions/core/categories/dao-governance.ts new file mode 100644 index 000000000..882a56553 --- /dev/null +++ b/packages/stateful/actions/core/categories/dao-governance.ts @@ -0,0 +1,32 @@ +import { + ActionCategoryKey, + ActionCategoryMaker, + ActionKey, +} from '@dao-dao/types' + +export const makeDaoGovernanceActionCategory: ActionCategoryMaker = ({ + t, + context, +}) => ({ + key: ActionCategoryKey.DaoGovernance, + label: t('actionCategory.daoGovernanceLabel'), + description: t('actionCategory.daoGovernanceDescription', { + context: context.type, + }), + actionKeys: [ + ActionKey.EnableMultipleChoice, + ActionKey.ManageStorageItems, + ActionKey.DaoAdminExec, + ActionKey.UpgradeV1ToV2, + ActionKey.CreateCrossChainAccount, + ActionKey.SetUpApprover, + ActionKey.VetoProposal, + ActionKey.ExecuteProposal, + ActionKey.ManageVetoableDaos, + ActionKey.ManageSubDaoPause, + ActionKey.NeutronOverruleSubDaoProposal, + ActionKey.UpdateProposalConfig, + ActionKey.UpdatePreProposeConfig, + ActionKey.CreateDao, + ], +}) diff --git a/packages/stateful/actions/core/categories/index.ts b/packages/stateful/actions/core/categories/index.ts new file mode 100644 index 000000000..43cf586a8 --- /dev/null +++ b/packages/stateful/actions/core/categories/index.ts @@ -0,0 +1,11 @@ +export * from './advanced' +export * from './authorizations' +export * from './chain-governance' +export * from './commonly-used' +export * from './dao-appearance' +export * from './dao-governance' +export * from './nfts' +export * from './smart-contracting' +export * from './subdaos' +export * from './treasury' +export * from './valence' diff --git a/packages/stateful/actions/core/nfts/index.ts b/packages/stateful/actions/core/categories/nfts.ts similarity index 59% rename from packages/stateful/actions/core/nfts/index.ts rename to packages/stateful/actions/core/categories/nfts.ts index f4cd89913..4cb3b8cb7 100644 --- a/packages/stateful/actions/core/nfts/index.ts +++ b/packages/stateful/actions/core/categories/nfts.ts @@ -2,15 +2,10 @@ import { ActionCategoryKey, ActionCategoryMaker, ActionChainContextType, + ActionKey, ChainId, } from '@dao-dao/types' -import { makeBurnNftAction } from './BurnNft' -import { makeCreateNftCollectionAction } from './CreateNftCollection' -import { makeManageCw721Action } from './ManageCw721' -import { makeMintNftAction } from './MintNft' -import { makeTransferNftAction } from './TransferNft' - export const makeManageNftsActionCategory: ActionCategoryMaker = ({ t, context, @@ -27,12 +22,12 @@ export const makeManageNftsActionCategory: ActionCategoryMaker = ({ description: t('actionCategory.nftsDescription', { context: context.type, }), - actionMakers: [ - makeCreateNftCollectionAction, - makeMintNftAction, - makeTransferNftAction, - makeBurnNftAction, - makeManageCw721Action, + actionKeys: [ + ActionKey.CreateNftCollection, + ActionKey.MintNft, + ActionKey.TransferNft, + ActionKey.BurnNft, + ActionKey.ManageCw721, ], } : null diff --git a/packages/stateful/actions/core/categories/smart-contracting.ts b/packages/stateful/actions/core/categories/smart-contracting.ts new file mode 100644 index 000000000..4f2cc1370 --- /dev/null +++ b/packages/stateful/actions/core/categories/smart-contracting.ts @@ -0,0 +1,22 @@ +import { + ActionCategoryKey, + ActionCategoryMaker, + ActionKey, +} from '@dao-dao/types' + +export const makeSmartContractingActionCategory: ActionCategoryMaker = ({ + t, +}) => ({ + key: ActionCategoryKey.SmartContracting, + label: t('actionCategory.smartContractingLabel'), + description: t('actionCategory.smartContractingDescription'), + actionKeys: [ + ActionKey.Instantiate, + ActionKey.Instantiate2, + ActionKey.Execute, + ActionKey.Migrate, + ActionKey.UpdateAdmin, + ActionKey.UploadCode, + ActionKey.FeeShare, + ], +}) diff --git a/packages/stateful/actions/core/subdaos/index.ts b/packages/stateful/actions/core/categories/subdaos.ts similarity index 63% rename from packages/stateful/actions/core/subdaos/index.ts rename to packages/stateful/actions/core/categories/subdaos.ts index de1005122..20b4e2fe9 100644 --- a/packages/stateful/actions/core/subdaos/index.ts +++ b/packages/stateful/actions/core/categories/subdaos.ts @@ -2,12 +2,9 @@ import { ActionCategoryKey, ActionCategoryMaker, ActionContextType, + ActionKey, } from '@dao-dao/types' -import { makeAcceptSubDaoAction } from './AcceptSubDao' -import { makeBecomeSubDaoAction } from './BecomeSubDao' -import { makeManageSubDaosAction } from './ManageSubDaos' - export const makeSubDaosActionCategory: ActionCategoryMaker = ({ t, context, @@ -15,16 +12,16 @@ export const makeSubDaosActionCategory: ActionCategoryMaker = ({ key: ActionCategoryKey.SubDaos, label: t('actionCategory.subDaosLabel'), description: t('actionCategory.subDaosDescription'), - actionMakers: + actionKeys: context.type === ActionContextType.Dao ? [ - makeManageSubDaosAction, - makeBecomeSubDaoAction, - makeAcceptSubDaoAction, + ActionKey.ManageSubDaos, + ActionKey.BecomeSubDao, + ActionKey.AcceptSubDao, ] : [ // Allow non-DAOs to become the parent of DAOs. This may be chain // governance or a DAO's polytone proxy for cross-chain SubDAOs. - makeAcceptSubDaoAction, + ActionKey.AcceptSubDao, ], }) diff --git a/packages/stateful/actions/core/categories/treasury.ts b/packages/stateful/actions/core/categories/treasury.ts new file mode 100644 index 000000000..4a4ae8d81 --- /dev/null +++ b/packages/stateful/actions/core/categories/treasury.ts @@ -0,0 +1,30 @@ +import { + ActionCategoryKey, + ActionCategoryMaker, + ActionKey, +} from '@dao-dao/types' + +export const makeTreasuryActionCategory: ActionCategoryMaker = ({ + t, + context, +}) => ({ + key: ActionCategoryKey.Treasury, + label: t('actionCategory.treasuryLabel', { + context: context.type, + }), + description: t('actionCategory.treasuryDescription', { + context: context.type, + }), + actionKeys: [ + ActionKey.Spend, + ActionKey.ManageStaking, + ActionKey.ManageVesting, + ActionKey.ManageCw20, + ActionKey.PerformTokenSwap, + ActionKey.WithdrawTokenSwap, + ActionKey.ConfigureVestingPayments, + ActionKey.EnableRetroactiveCompensation, + ActionKey.CommunityPoolSpend, + ActionKey.CommunityPoolDeposit, + ], +}) diff --git a/packages/stateful/actions/core/categories/valence.ts b/packages/stateful/actions/core/categories/valence.ts new file mode 100644 index 000000000..bcdf150c7 --- /dev/null +++ b/packages/stateful/actions/core/categories/valence.ts @@ -0,0 +1,27 @@ +import { + ActionCategoryKey, + ActionCategoryMaker, + ActionKey, +} from '@dao-dao/types' +import { actionContextSupportsValence } from '@dao-dao/utils' + +export const makeValenceActionCategory: ActionCategoryMaker = (options) => + actionContextSupportsValence(options) + ? { + key: ActionCategoryKey.Rebalancer, + label: options.t('actionCategory.rebalancerLabel', { + context: options.context.type, + }), + description: options.t('actionCategory.rebalancerDescription', { + context: options.context.type, + }), + actionKeys: [ + ActionKey.ConfigureRebalancer, + ActionKey.FundRebalancer, + ActionKey.WithdrawFromRebalancer, + ActionKey.PauseRebalancer, + ActionKey.ResumeRebalancer, + ActionKey.CreateValenceAccount, + ], + } + : null diff --git a/packages/stateful/actions/core/chain_governance/GovernanceDeposit/index.tsx b/packages/stateful/actions/core/chain_governance/GovernanceDeposit/index.tsx deleted file mode 100644 index 93719fbba..000000000 --- a/packages/stateful/actions/core/chain_governance/GovernanceDeposit/index.tsx +++ /dev/null @@ -1,286 +0,0 @@ -import { Coin } from '@cosmjs/stargate' -import { useCallback, useEffect } from 'react' -import { useFormContext } from 'react-hook-form' -import { constSelector, useRecoilValue, waitForAll } from 'recoil' - -import { - genericTokenSelector, - govParamsSelector, - govProposalSelector, - govProposalsSelector, -} from '@dao-dao/state' -import { - BankEmoji, - DaoSupportedChainPickerInput, - useCachedLoading, - useChain, -} from '@dao-dao/stateless' -import { ChainId, TokenType, makeStargateMessage } from '@dao-dao/types' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { ProposalStatus } from '@dao-dao/types/protobuf/codegen/cosmos/gov/v1beta1/gov' -import { MsgDeposit } from '@dao-dao/types/protobuf/codegen/cosmos/gov/v1beta1/tx' -import { - decodePolytoneExecuteMsg, - getChainAddressForActionOptions, - isDecodedStargateMsg, - maybeMakePolytoneExecuteMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { GovProposalActionDisplay } from '../../../../components' -import { TokenAmountDisplay } from '../../../../components/TokenAmountDisplay' -import { GovActionsProvider, useActionOptions } from '../../../react' -import { - GovernanceDepositData, - GovernanceDepositComponent as StatelessGovernanceDepositComponent, -} from './Component' - -const Component: ActionComponent = ( - props -) => { - const { context } = useActionOptions() - const { setValue } = useFormContext() - - return ( - <> - {context.type === ActionContextType.Dao && ( - { - // Clear fields on chain change. - setValue((props.fieldNamePrefix + 'proposalId') as 'proposalId', '') - setValue((props.fieldNamePrefix + 'deposit') as 'deposit', []) - }} - onlyDaoChainIds - /> - )} - - - - - - ) -} - -const InnerComponent: ActionComponent = ( - props -) => { - const { isCreating, fieldNamePrefix } = props - const { chain_id: chainId } = useChain() - 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.proposals.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( - govParamsSelector({ - chainId, - }) - ) - - // On proposal change, update deposit to remaining needed. - useEffect(() => { - const proposalSelected = - proposalId && - proposalOptions?.proposals.find((p) => p.id.toString() === proposalId) - if (!proposalSelected) { - return - } - - const minDeposit = govParams.minDeposit[0] - const missingDeposit = - BigInt(minDeposit.amount) - - BigInt( - proposalSelected.proposal.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?.proposals.length && !proposalId) { - setValue( - (fieldNamePrefix + 'proposalId') as 'proposalId', - proposalOptions.proposals[0].id.toString() - ) - } - }, [isCreating, proposalOptions, proposalId, setValue, fieldNamePrefix]) - - const depositTokens = useCachedLoading( - waitForAll( - govParams.minDeposit.map(({ denom }) => - genericTokenSelector({ - type: TokenType.Native, - denomOrAddress: denom, - chainId, - }) - ) - ), - [] - ) - - return ( - - ) -} - -export const makeGovernanceDepositAction: ActionMaker = ( - options -) => { - const { - t, - chain: { chain_id: currentChainId }, - context, - } = options - - if ( - // Governance module cannot participate in governance. - context.type === ActionContextType.Gov || - // Neutron does not use the x/gov module. - currentChainId === ChainId.NeutronMainnet || - currentChainId === ChainId.NeutronTestnet - ) { - return null - } - - const useDefaults: UseDefaults = () => ({ - chainId: currentChainId, - proposalId: '', - deposit: [], - }) - - const useTransformToCosmos: UseTransformToCosmos< - GovernanceDepositData - > = () => - useCallback( - ({ chainId, proposalId, deposit }) => - maybeMakePolytoneExecuteMessage( - currentChainId, - chainId, - makeStargateMessage({ - stargate: { - typeUrl: MsgDeposit.typeUrl, - value: { - proposalId: BigInt(proposalId || '0'), - depositor: getChainAddressForActionOptions(options, chainId), - amount: deposit.map(({ denom, amount }) => ({ - denom, - amount: BigInt(amount).toString(), - })), - } as MsgDeposit, - }, - }) - ), - [] - ) - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - let chainId = currentChainId - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - } - - return isDecodedStargateMsg(msg) && - objectMatchesStructure(msg.stargate.value, { - proposalId: {}, - depositor: {}, - amount: {}, - }) && - // Make sure this is a deposit message. - msg.stargate.typeUrl === MsgDeposit.typeUrl - ? { - match: true, - data: { - chainId, - 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/GovernanceVote/index.tsx b/packages/stateful/actions/core/chain_governance/GovernanceVote/index.tsx deleted file mode 100644 index 88346cb83..000000000 --- a/packages/stateful/actions/core/chain_governance/GovernanceVote/index.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import { useCallback, useEffect } from 'react' -import { useFormContext } from 'react-hook-form' -import { constSelector, useRecoilValue, useRecoilValueLoadable } from 'recoil' - -import { - govProposalSelector, - govProposalVoteSelector, - govProposalsSelector, -} from '@dao-dao/state' -import { - BallotDepositEmoji, - ChainProvider, - DaoSupportedChainPickerInput, - Loader, -} from '@dao-dao/stateless' -import { - ChainId, - cwVoteOptionToGovVoteOption, - govVoteOptionToCwVoteOption, -} from '@dao-dao/types' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { - ProposalStatus, - VoteOption, -} from '@dao-dao/types/protobuf/codegen/cosmos/gov/v1beta1/gov' -import { MsgVote } from '@dao-dao/types/protobuf/codegen/cosmos/gov/v1beta1/tx' -import { - decodePolytoneExecuteMsg, - getChainAddressForActionOptions, - isDecodedStargateMsg, - loadableToLoadingData, - maybeMakePolytoneExecuteMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { - GovProposalActionDisplay, - SuspenseLoader, -} from '../../../../components' -import { TokenAmountDisplay } from '../../../../components/TokenAmountDisplay' -import { GovActionsProvider, useActionOptions } from '../../../react' -import { - GovernanceVoteData, - GovernanceVoteComponent as StatelessGovernanceVoteComponent, -} from './Component' - -const Component: ActionComponent = (props) => { - const { isCreating, fieldNamePrefix } = props - const options = useActionOptions() - const { watch, setValue, setError, clearErrors } = - useFormContext() - - const chainId = watch((fieldNamePrefix + 'chainId') as 'chainId') - const proposalId = watch( - (props.fieldNamePrefix + 'proposalId') as 'proposalId' - ) - - const openProposalsLoadable = useRecoilValueLoadable( - isCreating - ? govProposalsSelector({ - status: ProposalStatus.PROPOSAL_STATUS_VOTING_PERIOD, - chainId, - }) - : constSelector(undefined) - ) - - // Prevent action from being submitted if there are no open proposals. - useEffect(() => { - if ( - openProposalsLoadable.state === 'hasValue' && - openProposalsLoadable.contents?.proposals.length === 0 - ) { - setError((fieldNamePrefix + 'proposalId') as 'proposalId', { - type: 'manual', - }) - } else { - clearErrors((fieldNamePrefix + 'proposalId') as 'proposalId') - } - }, [openProposalsLoadable, 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 address = getChainAddressForActionOptions(options, chainId) - const existingVotesLoading = loadableToLoadingData( - useRecoilValueLoadable( - proposalId && address - ? govProposalVoteSelector({ - proposalId: Number(proposalId), - voter: address, - chainId, - }) - : constSelector(undefined) - ), - undefined - ) - - // Select first proposal once loaded if nothing selected. - useEffect(() => { - if ( - isCreating && - openProposalsLoadable.state === 'hasValue' && - openProposalsLoadable.contents?.proposals.length && - !proposalId - ) { - setValue( - (fieldNamePrefix + 'proposalId') as 'proposalId', - openProposalsLoadable.contents.proposals[0].id.toString() - ) - } - }, [isCreating, openProposalsLoadable, proposalId, setValue, fieldNamePrefix]) - - return ( - <> - {options.context.type === ActionContextType.Dao && ( - - // Clear proposal on chain change. - setValue((fieldNamePrefix + 'proposalId') as 'proposalId', '') - } - onlyDaoChainIds - /> - )} - - - - } - forceFallback={openProposalsLoadable.state !== 'hasValue'} - > - - - - - - ) -} - -export const makeGovernanceVoteAction: ActionMaker = ({ - t, - chain: { chain_id: currentChainId }, - context, -}) => { - if ( - // Governance module cannot participate in governance. - context.type === ActionContextType.Gov || - // Neutron does not use the x/gov module. - currentChainId === ChainId.NeutronMainnet || - currentChainId === ChainId.NeutronTestnet - ) { - return null - } - - const useDefaults: UseDefaults = () => ({ - chainId: currentChainId, - proposalId: '', - vote: VoteOption.VOTE_OPTION_ABSTAIN, - }) - - const useTransformToCosmos: UseTransformToCosmos = () => - useCallback( - ({ chainId, proposalId, vote }) => - maybeMakePolytoneExecuteMessage(currentChainId, chainId, { - gov: { - vote: { - proposal_id: Number(proposalId || '-1'), - vote: govVoteOptionToCwVoteOption(vote), - }, - }, - }), - [] - ) - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - let chainId = currentChainId - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - } - - return isDecodedStargateMsg(msg) && - objectMatchesStructure(msg.stargate.value, { - proposalId: {}, - voter: {}, - option: {}, - }) && - // If vote Stargate message. - msg.stargate.typeUrl === MsgVote.typeUrl - ? { - match: true, - data: { - chainId, - proposalId: msg.stargate.value.proposalId.toString(), - vote: msg.stargate.value.option, - }, - } - : // If vote gov CosmWasm message. - objectMatchesStructure(msg, { - gov: { - vote: { - proposal_id: {}, - vote: {}, - }, - }, - }) - ? { - match: true, - data: { - chainId, - proposalId: msg.gov.vote.proposal_id.toString(), - vote: cwVoteOptionToGovVoteOption(msg.gov.vote.vote), - }, - } - : { - match: false, - } - } - - return { - key: ActionKey.GovernanceVote, - Icon: BallotDepositEmoji, - label: t('title.voteOnGovernanceProposal'), - description: t('info.voteOnGovernanceProposalDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - } -} diff --git a/packages/stateful/actions/core/chain_governance/ValidatorActions/index.tsx b/packages/stateful/actions/core/chain_governance/ValidatorActions/index.tsx deleted file mode 100644 index 2c7685001..000000000 --- a/packages/stateful/actions/core/chain_governance/ValidatorActions/index.tsx +++ /dev/null @@ -1,293 +0,0 @@ -import { fromBase64, toBase64 } from '@cosmjs/encoding' -import cloneDeep from 'lodash.clonedeep' -import { useCallback } from 'react' - -import { PickEmoji } from '@dao-dao/stateless' -import { ChainId, makeStargateMessage } from '@dao-dao/types' -import { - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { PubKey } from '@dao-dao/types/protobuf/codegen/cosmos/crypto/ed25519/keys' -import { MsgWithdrawValidatorCommission } from '@dao-dao/types/protobuf/codegen/cosmos/distribution/v1beta1/tx' -import { MsgUnjail } from '@dao-dao/types/protobuf/codegen/cosmos/slashing/v1beta1/tx' -import { - MsgCreateValidator, - MsgEditValidator, -} from '@dao-dao/types/protobuf/codegen/cosmos/staking/v1beta1/tx' -import { - decodePolytoneExecuteMsg, - getChainAddressForActionOptions, - getChainForChainId, - getNativeTokenForChainId, - isDecodedStargateMsg, - maybeMakePolytoneExecuteMessage, - toValidatorAddress, -} from '@dao-dao/utils' - -import { - ValidatorActionsComponent as Component, - VALIDATOR_ACTION_TYPES, - ValidatorActionsData, -} from './Component' - -export const makeValidatorActionsAction: ActionMaker = ( - options -) => { - const { - t, - chain: { chain_id: currentChainId }, - context, - } = options - - if ( - // Governance module cannot run a validator. - context.type === ActionContextType.Gov || - // Neutron does not have validators. - currentChainId === ChainId.NeutronMainnet || - currentChainId === ChainId.NeutronTestnet - ) { - return null - } - - const getValidatorAddress = (chainId: string) => - toValidatorAddress( - getChainAddressForActionOptions(options, chainId) || '', - getChainForChainId(chainId).bech32_prefix - ) - - const useTransformToCosmos: UseTransformToCosmos = () => - useCallback( - ({ - chainId, - validatorActionTypeUrl: validatorActionType, - createMsg, - editMsg, - }: ValidatorActionsData) => { - const validatorAddress = getValidatorAddress(chainId) - - let msg - switch (validatorActionType) { - case MsgWithdrawValidatorCommission.typeUrl: - msg = makeStargateMessage({ - stargate: { - typeUrl: MsgWithdrawValidatorCommission.typeUrl, - value: { - validatorAddress, - } as MsgWithdrawValidatorCommission, - }, - }) - break - case MsgCreateValidator.typeUrl: - const parsed = JSON.parse(createMsg) - msg = makeStargateMessage({ - stargate: { - typeUrl: MsgCreateValidator.typeUrl, - value: { - ...parsed, - pubkey: PubKey.toProtoMsg({ - key: fromBase64(parsed.pubkey.value.key), - }), - }, - }, - }) - break - case MsgEditValidator.typeUrl: - msg = makeStargateMessage({ - stargate: { - typeUrl: MsgEditValidator.typeUrl, - value: JSON.parse(editMsg), - }, - }) - break - case MsgUnjail.typeUrl: - msg = makeStargateMessage({ - stargate: { - typeUrl: MsgUnjail.typeUrl, - value: { - validatorAddr: validatorAddress, - } as MsgUnjail, - }, - }) - break - default: - throw Error('Unrecogonized validator action type') - } - - return maybeMakePolytoneExecuteMessage(currentChainId, chainId, msg) - }, - [] - ) - - const useDefaults: UseDefaults = () => ({ - chainId: currentChainId, - validatorActionTypeUrl: VALIDATOR_ACTION_TYPES[0].typeUrl, - createMsg: JSON.stringify( - { - description: { - moniker: '', - identity: '', - website: '', - securityContact: '', - details: '', - }, - commission: { - rate: '0.05', - maxRate: '0.2', - maxChangeRate: '0.1', - }, - minSelfDelegation: '1', - delegatorAddress: getChainAddressForActionOptions( - options, - currentChainId - ), - validatorAddress: getValidatorAddress(currentChainId), - pubkey: { - typeUrl: PubKey.typeUrl, - value: { - key: '', - }, - }, - value: { - denom: getNativeTokenForChainId(currentChainId).denomOrAddress, - amount: '1000000', - }, - }, - null, - 2 - ), - editMsg: JSON.stringify( - { - description: { - moniker: '', - identity: '', - website: '', - securityContact: '', - details: '', - }, - commissionRate: '0.05', - minSelfDelegation: '1', - validatorAddress: getValidatorAddress(currentChainId), - }, - null, - 2 - ), - }) - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - let chainId = currentChainId - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - } - - const thisAddress = getChainAddressForActionOptions(options, chainId) - const validatorAddress = getValidatorAddress(chainId) - - const data = useDefaults() as ValidatorActionsData - - if ( - !thisAddress || - // Check this is a stargate message. - !isDecodedStargateMsg(msg) - ) { - return { match: false } - } - - // Check that the type URL is a validator message, set data accordingly. - const decodedData = cloneDeep(data) - decodedData.chainId = chainId - - switch (msg.stargate.typeUrl) { - case MsgWithdrawValidatorCommission.typeUrl: - if ( - (msg.stargate.value as MsgWithdrawValidatorCommission) - .validatorAddress !== validatorAddress - ) { - return { match: false } - } - - decodedData.validatorActionTypeUrl = - MsgWithdrawValidatorCommission.typeUrl - break - - case MsgCreateValidator.typeUrl: - if ( - (msg.stargate.value as MsgCreateValidator).delegatorAddress !== - thisAddress || - (msg.stargate.value as MsgCreateValidator).validatorAddress !== - validatorAddress - ) { - return { match: false } - } - - decodedData.validatorActionTypeUrl = MsgCreateValidator.typeUrl - const decodedPubkey = PubKey.decode( - (msg.stargate.value as MsgCreateValidator).pubkey!.value - ) - decodedData.createMsg = JSON.stringify( - { - ...msg.stargate.value, - pubkey: { - typeUrl: msg.stargate.value.pubkey!.typeUrl, - value: { - key: toBase64(decodedPubkey.key), - }, - }, - }, - null, - 2 - ) - break - - case MsgEditValidator.typeUrl: - if ( - (msg.stargate.value as MsgEditValidator).validatorAddress !== - validatorAddress - ) { - return { match: false } - } - - decodedData.validatorActionTypeUrl = MsgEditValidator.typeUrl - decodedData.editMsg = JSON.stringify(msg.stargate.value, null, 2) - break - - case MsgUnjail.typeUrl: - if ( - (msg.stargate.value as MsgUnjail).validatorAddr !== validatorAddress - ) { - return { match: false } - } - - decodedData.validatorActionTypeUrl = MsgUnjail.typeUrl - break - - default: - // No validator action type URL match, so return a false match. - return { match: false } - } - - return { - match: true, - data: decodedData, - } - } - - return { - key: ActionKey.ValidatorActions, - Icon: PickEmoji, - label: t('title.validatorActions'), - description: t('info.validatorActionsDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - } -} diff --git a/packages/stateful/actions/core/chain_governance/index.ts b/packages/stateful/actions/core/chain_governance/index.ts deleted file mode 100644 index def274a18..000000000 --- a/packages/stateful/actions/core/chain_governance/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ActionCategoryKey, ActionCategoryMaker } from '@dao-dao/types' - -import { makeGovernanceDepositAction } from './GovernanceDeposit' -import { makeGovernanceProposalAction } from './GovernanceProposal' -import { makeGovernanceVoteAction } from './GovernanceVote' -import { makeValidatorActionsAction } from './ValidatorActions' - -export const makeChainGovernanceActionCategory: ActionCategoryMaker = ({ - t, -}) => ({ - key: ActionCategoryKey.ChainGovernance, - label: t('actionCategory.chainGovernanceLabel'), - description: t('actionCategory.chainGovernanceDescription'), - actionMakers: [ - makeGovernanceVoteAction, - makeGovernanceProposalAction, - makeGovernanceDepositAction, - makeValidatorActionsAction, - ], -}) diff --git a/packages/stateful/actions/core/commonlyUsed.ts b/packages/stateful/actions/core/commonlyUsed.ts deleted file mode 100644 index 5788e307b..000000000 --- a/packages/stateful/actions/core/commonlyUsed.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { ActionCategoryKey, ActionCategoryMaker } from '@dao-dao/types' - -import { makeAuthzGrantRevokeAction } from './authorizations/AuthzGrantRevoke' -import { makeGovernanceVoteAction } from './chain_governance/GovernanceVote' -import { makeCreateCrossChainAccountAction } from './dao_governance/CreateCrossChainAccount' -import { makeUpgradeV1ToV2Action } from './dao_governance/UpgradeV1ToV2' -import { makeExecuteAction } from './smart_contracting/Execute' -import { makeInstantiateAction } from './smart_contracting/Instantiate' -import { makeCommunityPoolSpendAction } from './treasury/CommunityPoolSpend' -import { makeConfigureVestingPaymentsAction } from './treasury/ConfigureVestingPayments' -import { makeManageStakingAction } from './treasury/ManageStaking' -import { makeManageVestingAction } from './treasury/ManageVesting' -import { makeSpendAction } from './treasury/Spend' - -export const makeCommonlyUsedCategory: ActionCategoryMaker = ({ t }) => ({ - key: ActionCategoryKey.CommonlyUsed, - label: t('actionCategory.commonlyUsedLabel'), - description: t('actionCategory.commonlyUsedDescription'), - actionMakers: [ - makeUpgradeV1ToV2Action, - makeSpendAction, - makeCommunityPoolSpendAction, - makeManageStakingAction, - makeCreateCrossChainAccountAction, - makeManageVestingAction, - makeAuthzGrantRevokeAction, - makeGovernanceVoteAction, - makeExecuteAction, - makeInstantiateAction, - makeConfigureVestingPaymentsAction, - ], -}) diff --git a/packages/stateful/actions/core/dao_appearance/ManageWidgets/index.tsx b/packages/stateful/actions/core/dao_appearance/ManageWidgets/index.tsx deleted file mode 100644 index d879f7e9d..000000000 --- a/packages/stateful/actions/core/dao_appearance/ManageWidgets/index.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { useCallback, useMemo } from 'react' - -import { HammerAndWrenchEmoji, Loader } from '@dao-dao/stateless' -import { Feature } from '@dao-dao/types' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { - getWidgetStorageItemKey, - makeWasmMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { SuspenseLoader } from '../../../../components' -import { getWidgets, useWidgets } from '../../../../widgets' -import { useActionOptions } from '../../../react' -import { - ManageWidgetsData, - ManageWidgetsComponent as StatelessManageWidgetsComponent, -} from './Component' - -const useDefaults: UseDefaults = () => ({ - mode: 'set', - id: '', - values: {}, -}) - -const Component: ActionComponent = (props) => { - const { - chain: { chain_id: chainId }, - } = useActionOptions() - const availableWidgets = useMemo(() => getWidgets(chainId), [chainId]) - const loadingExistingWidgets = useWidgets() - - return ( - } - forceFallback={loadingExistingWidgets.loading} - > - {!loadingExistingWidgets.loading && ( - daoWidget - ), - SuspenseLoader, - }} - /> - )} - - ) -} - -export const makeManageWidgetsAction: ActionMaker = ({ - t, - context, - address, -}) => { - if (context.type !== ActionContextType.Dao) { - return null - } - - const valueKey = context.dao.info.supportedFeatures[ - Feature.StorageItemValueKey - ] - ? 'value' - : 'addr' - - const useTransformToCosmos: UseTransformToCosmos = () => - useCallback( - ({ mode, id, values }) => - makeWasmMessage({ - wasm: { - execute: { - contract_addr: address, - funds: [], - msg: - mode === 'set' - ? { - set_item: { - key: getWidgetStorageItemKey(id), - [valueKey]: JSON.stringify(values), - }, - } - : { - remove_item: { - key: getWidgetStorageItemKey(id), - }, - }, - }, - }, - }), - [] - ) - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - if ( - objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: {}, - }, - }, - }) && - msg.wasm.execute.contract_addr === address && - (('set_item' in msg.wasm.execute.msg && - msg.wasm.execute.msg.set_item.key.startsWith( - getWidgetStorageItemKey('') - )) || - ('remove_item' in msg.wasm.execute.msg && - msg.wasm.execute.msg.remove_item.key.startsWith( - getWidgetStorageItemKey('') - ))) - ) { - const mode = 'set_item' in msg.wasm.execute.msg ? 'set' : 'delete' - - let values = {} - if (mode === 'set') { - try { - values = JSON.parse(msg.wasm.execute.msg.set_item[valueKey]) - } catch (err) { - console.error(err) - } - } - - return { - match: true, - data: { - mode, - id: (mode === 'set' - ? msg.wasm.execute.msg.set_item.key - : msg.wasm.execute.msg.remove_item.key - ).replace(new RegExp(`^${getWidgetStorageItemKey('')}`), ''), - values, - }, - } - } - - return { match: false } - } - - return { - key: ActionKey.ManageWidgets, - Icon: HammerAndWrenchEmoji, - label: t('title.manageWidgets'), - description: t('info.manageWidgetsDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - } -} diff --git a/packages/stateful/actions/core/dao_appearance/UpdateInfo/index.tsx b/packages/stateful/actions/core/dao_appearance/UpdateInfo/index.tsx deleted file mode 100644 index 4a8733c34..000000000 --- a/packages/stateful/actions/core/dao_appearance/UpdateInfo/index.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { useCallback } from 'react' - -import { DaoDaoCoreSelectors } from '@dao-dao/state' -import { InfoEmoji, useCachedLoadingWithError } from '@dao-dao/stateless' -import { - ActionContextType, - ActionMaker, - ChainId, - ContractVersion, -} from '@dao-dao/types' -import { - ActionKey, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { makeWasmMessage, objectMatchesStructure } from '@dao-dao/utils' - -import { UpdateInfoComponent as Component, UpdateInfoData } from './Component' - -export const makeUpdateInfoAction: ActionMaker = ({ - t, - address, - context, - chain: { chain_id: chainId }, -}) => { - // Only DAOs. - if (context.type !== ActionContextType.Dao) { - return null - } - - const useDefaults: UseDefaults = () => { - const config = useCachedLoadingWithError( - DaoDaoCoreSelectors.configSelector({ - chainId, - contractAddress: address, - params: [], - }) - ) - - return config.loading - ? undefined - : config.errored - ? config.error - : { - ...config.data, - } - } - - const useTransformToCosmos: UseTransformToCosmos = () => - useCallback( - (data: UpdateInfoData) => - makeWasmMessage({ - wasm: { - execute: { - contract_addr: address, - funds: [], - msg: { - update_config: { - config: - context.dao.chainId === ChainId.NeutronMainnet && - context.dao.coreVersion === - ContractVersion.V2AlphaNeutronFork - ? // The Neutron fork DAO has a different config structure. - { - name: data.name, - description: data.description, - dao_uri: 'dao_uri' in data ? data.dao_uri : null, - } - : { - ...data, - // Replace empty string with null. - image_url: data.image_url?.trim() || null, - }, - }, - }, - }, - }, - }), - [] - ) - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => - objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - update_config: { - config: { - name: {}, - description: {}, - }, - }, - }, - }, - }, - }) && msg.wasm.execute.contract_addr === address - ? { - match: true, - data: { - name: msg.wasm.execute.msg.update_config.config.name, - description: msg.wasm.execute.msg.update_config.config.description, - - // Only add image url if in the message. - ...(!!msg.wasm.execute.msg.update_config.config.image_url && { - image_url: msg.wasm.execute.msg.update_config.config.image_url, - }), - - // V1 and V2 passthrough - automatically_add_cw20s: - msg.wasm.execute.msg.update_config.config.automatically_add_cw20s, - automatically_add_cw721s: - msg.wasm.execute.msg.update_config.config - .automatically_add_cw721s, - - // V2 passthrough - // Only add dao URI if in the message. - ...('dao_uri' in msg.wasm.execute.msg.update_config.config && { - dao_uri: msg.wasm.execute.msg.update_config.config.dao_uri, - }), - }, - } - : { - match: false, - } - - return { - key: ActionKey.UpdateInfo, - Icon: InfoEmoji, - label: t('title.updateInfo'), - description: t('info.updateInfoActionDescription'), - notReusable: true, - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - } -} diff --git a/packages/stateful/actions/core/dao_governance/CreateCrossChainAccount/index.tsx b/packages/stateful/actions/core/dao_governance/CreateCrossChainAccount/index.tsx deleted file mode 100644 index 3be2c27a6..000000000 --- a/packages/stateful/actions/core/dao_governance/CreateCrossChainAccount/index.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { useCallback } from 'react' - -import { ChainEmoji } from '@dao-dao/stateless' -import { - ActionChainContextType, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types' -import { - decodePolytoneExecuteMsg, - maybeMakePolytoneExecuteMessage, -} from '@dao-dao/utils' - -import { - CreateCrossChainAccountComponent as Component, - CreateCrossChainAccountData, -} from './Component' - -export const makeCreateCrossChainAccountAction: ActionMaker< - CreateCrossChainAccountData -> = ({ t, context, chain, chainContext }) => { - // Only allow using this action in DAOs. - if ( - context.type !== ActionContextType.Dao || - chainContext.type !== ActionChainContextType.Supported - ) { - return null - } - - const missingChainIds = Object.keys( - chainContext.config.polytone || {} - ).filter((chainId) => !(chainId in context.dao.info.polytoneProxies)) - - const useDefaults: UseDefaults = () => ({ - chainId: missingChainIds[0], - }) - - const useTransformToCosmos: UseTransformToCosmos< - CreateCrossChainAccountData - > = () => - useCallback( - ({ chainId }) => maybeMakePolytoneExecuteMessage(chain.chain_id, chainId), - [] - ) - - const useDecodedCosmosMsg: UseDecodedCosmosMsg< - CreateCrossChainAccountData - > = (msg: Record) => { - const decodedPolytone = decodePolytoneExecuteMsg( - chain.chain_id, - msg, - 'zero' - ) - return decodedPolytone.match - ? { - match: true, - data: { - chainId: decodedPolytone.chainId, - }, - } - : { - match: false, - } - } - - return { - key: ActionKey.CreateCrossChainAccount, - Icon: ChainEmoji, - label: t('title.createCrossChainAccount'), - description: t('info.createCrossChainAccountDescription'), - // Don't show action if no accounts can be created. - hideFromPicker: missingChainIds.length === 0, - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - } -} diff --git a/packages/stateful/actions/core/dao_governance/CreateDao/index.tsx b/packages/stateful/actions/core/dao_governance/CreateDao/index.tsx deleted file mode 100644 index 9521623de..000000000 --- a/packages/stateful/actions/core/dao_governance/CreateDao/index.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { useQueryClient } from '@tanstack/react-query' -import { useCallback } from 'react' - -import { DaoEmoji, useChain } from '@dao-dao/stateless' -import { - ActionComponent, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types' -import { decodeJsonFromBase64, objectMatchesStructure } from '@dao-dao/utils' - -import { LinkWrapper } from '../../../../components' -import { useQueryLoadingDataWithError } from '../../../../hooks' -import { daoQueries } from '../../../../queries' -import { CreateDaoComponent, CreateDaoData } from './Component' - -const Component: ActionComponent = (props) => { - const { chain_id: chainId } = useChain() - - // If admin is set, attempt to load parent DAO info. - const parentDao = useQueryLoadingDataWithError( - daoQueries.parentInfo( - useQueryClient(), - props.data.admin - ? { - chainId, - parentAddress: props.data.admin, - } - : undefined - ) - ) - - return ( - - ) -} - -const useDefaults: UseDefaults = () => ({ - name: '', - description: '', - imageUrl: '', -}) - -const useTransformToCosmos: UseTransformToCosmos = () => - useCallback(() => undefined, []) - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = (msg) => { - // Normal DAO creation via self-admin factory. - if ( - objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - instantiate_contract_with_self_admin: { - code_id: {}, - instantiate_msg: {}, - label: {}, - }, - }, - }, - }, - }) - ) { - try { - const decoded = decodeJsonFromBase64( - msg.wasm.execute.msg.instantiate_contract_with_self_admin - .instantiate_msg - ) - if ( - !objectMatchesStructure(decoded, { - admin: {}, - automatically_add_cw20s: {}, - automatically_add_cw721s: {}, - name: {}, - description: {}, - image_url: {}, - proposal_modules_instantiate_info: {}, - voting_module_instantiate_info: {}, - }) - ) { - return { - match: false, - } - } - - return { - match: true, - data: { - admin: decoded.admin, - name: decoded.name, - description: decoded.description, - imageUrl: decoded.image_url, - }, - } - } catch {} - } - - // SubDAO creation with parent DAO as admin. - if ( - objectMatchesStructure(msg, { - wasm: { - instantiate: { - code_id: {}, - funds: {}, - label: {}, - msg: { - admin: {}, - automatically_add_cw20s: {}, - automatically_add_cw721s: {}, - name: {}, - description: {}, - image_url: {}, - proposal_modules_instantiate_info: {}, - voting_module_instantiate_info: {}, - }, - }, - }, - }) - ) { - return { - match: true, - data: { - admin: msg.wasm.instantiate.msg.admin, - name: msg.wasm.instantiate.msg.name, - description: msg.wasm.instantiate.msg.description, - imageUrl: msg.wasm.instantiate.msg.image_url, - }, - } - } - - return { - match: false, - } -} - -export const makeCreateDaoAction: ActionMaker = ({ t }) => ({ - key: ActionKey.CreateDao, - label: t('title.createDao'), - description: t('info.createDaoActionDescription'), - Icon: DaoEmoji, - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - // Only use for rendering. - hideFromPicker: true, -}) diff --git a/packages/stateful/actions/core/dao_governance/EnableMultipleChoice/index.tsx b/packages/stateful/actions/core/dao_governance/EnableMultipleChoice/index.tsx deleted file mode 100644 index 3359166c2..000000000 --- a/packages/stateful/actions/core/dao_governance/EnableMultipleChoice/index.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import { useCallback } from 'react' -import { constSelector } from 'recoil' - -import { - DaoProposalSingleCommonSelectors, - genericTokenSelector, -} from '@dao-dao/state/recoil' -import { NumbersEmoji, useCachedLoadingWithError } from '@dao-dao/stateless' -import { - ContractVersion, - DepositRefundPolicy, - Feature, - TokenType, -} from '@dao-dao/types' -import { - ActionChainContextType, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { PercentageThreshold } from '@dao-dao/types/contracts/DaoProposalMultiple' -import { - ContractName, - DaoProposalMultipleAdapterId, - convertCosmosVetoConfigToVeto, - convertDurationToDurationWithUnits, - convertMicroDenomToDenomWithDecimals, - getNativeTokenForChainId, - makeWasmMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { - DaoProposalMultipleAdapter, - DaoProposalSingleAdapter, -} from '../../../../proposal-module-adapter' -import { depositInfoSelector } from '../../../../proposal-module-adapter/adapters/DaoProposalSingle/common' -import { makeDefaultNewDao } from '../../../../recoil' -import { EnableMultipleChoiceComponent as Component } from './Component' - -type EnableMultipleChoiceData = {} - -const useDefaults: UseDefaults = () => ({}) - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => - objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - update_proposal_modules: { - to_add: {}, - }, - }, - }, - }, - }) && - msg.wasm.execute.msg.update_proposal_modules.to_add.length === 1 && - objectMatchesStructure( - msg.wasm.execute.msg.update_proposal_modules.to_add[0], - { - admin: {}, - code_id: {}, - label: {}, - msg: {}, - } - ) && - msg.wasm.execute.msg.update_proposal_modules.to_add[0].label.endsWith( - DaoProposalMultipleAdapterId - ) - ? { - match: true, - data: {}, - } - : { - match: false, - } - -export const makeEnableMultipleChoiceAction: ActionMaker< - EnableMultipleChoiceData -> = ({ t, address, context, chain: { chain_id: chainId }, chainContext }) => { - // Disallow usage if: - // - not a DAO - // - DAO doesn't support multiple choice proposals - // - Neutron fork SubDAO - // - chain is not supported (type-check, implied by DAO check) - // - // Disallows creation at the bottom of this function if: - // - multiple choice proposal module already exists - // - single-choice approval flow is enabled, since multiple choice doesn't - // support approval flow right now and that would be confusing. - if ( - context.type !== ActionContextType.Dao || - !context.dao.info.supportedFeatures[Feature.MultipleChoiceProposals] || - // Neutron fork SubDAOs don't support multiple choice proposals due to the - // timelock/overrule system only being designed for single choice proposals. - context.dao.coreVersion === ContractVersion.V2AlphaNeutronFork || - chainContext.type !== ActionChainContextType.Supported - ) { - return null - } - - const useTransformToCosmos: UseTransformToCosmos< - EnableMultipleChoiceData - > = () => { - const singleChoiceProposal = context.dao.proposalModules.find( - ({ contractName }) => - DaoProposalSingleAdapter.contractNames.some((name) => - contractName.includes(name) - ) - ) - if (!singleChoiceProposal) { - throw new Error('No single choice proposal module found') - } - - const config = useCachedLoadingWithError( - DaoProposalSingleCommonSelectors.configSelector({ - contractAddress: singleChoiceProposal.address, - chainId, - }) - ) - const depositInfo = useCachedLoadingWithError( - depositInfoSelector({ - chainId, - proposalModuleAddress: singleChoiceProposal.address, - version: singleChoiceProposal.version, - preProposeAddress: singleChoiceProposal.prePropose?.address ?? null, - }) - ) - const depositInfoToken = useCachedLoadingWithError( - depositInfo.loading - ? undefined - : depositInfo.errored || !depositInfo.data - ? constSelector(undefined) - : genericTokenSelector({ - chainId, - type: - 'cw20' in depositInfo.data.denom - ? TokenType.Cw20 - : TokenType.Native, - denomOrAddress: - 'cw20' in depositInfo.data.denom - ? depositInfo.data.denom.cw20 - : depositInfo.data.denom.native, - }) - ) - - return useCallback(() => { - if ( - config.loading || - config.errored || - depositInfo.loading || - depositInfo.errored || - depositInfoToken.loading || - depositInfoToken.errored - ) { - return - } - - const quorum: PercentageThreshold = - 'threshold_quorum' in config.data.threshold - ? config.data.threshold.threshold_quorum.quorum - : { - percent: '0.2', - } - - const info = DaoProposalMultipleAdapter.daoCreation.getInstantiateInfo( - chainContext.config, - { - ...makeDefaultNewDao(chainId), - // Only the name is used in this function to pick the contract label. - name: context.dao.name, - }, - { - ...makeDefaultNewDao(chainId).votingConfig, - enableMultipleChoice: true, - moduleInstantiateFundsUnsupported: - !context.dao.info.supportedFeatures[Feature.ModuleInstantiateFunds], - quorum: { - majority: 'majority' in quorum, - value: 'majority' in quorum ? 50 : Number(quorum.percent) * 100, - }, - votingDuration: convertDurationToDurationWithUnits( - config.data.max_voting_period - ), - proposalDeposit: { - enabled: !!depositInfo.data && !!depositInfoToken.data, - amount: - depositInfo.data && depositInfoToken.data - ? convertMicroDenomToDenomWithDecimals( - depositInfo.data.amount, - depositInfoToken.data.decimals - ) - : 10, - type: - depositInfo.data && 'cw20' in depositInfo.data.denom - ? 'cw20' - : 'native', - denomOrAddress: depositInfo.data - ? 'cw20' in depositInfo.data.denom - ? depositInfo.data.denom.cw20 - : depositInfo.data.denom.native - : getNativeTokenForChainId(chainId).denomOrAddress, - token: depositInfoToken.data, - refundPolicy: - depositInfo.data?.refund_policy ?? DepositRefundPolicy.OnlyPassed, - }, - anyoneCanPropose: singleChoiceProposal.prePropose - ? 'anyone' in singleChoiceProposal.prePropose.submissionPolicy - : // If no pre-propose module, default to only members can propose. - false, - allowRevoting: config.data.allow_revoting, - approver: { - enabled: false, - address: '', - }, - veto: convertCosmosVetoConfigToVeto( - 'veto' in config.data ? config.data.veto : null - ), - }, - t - ) - - return makeWasmMessage({ - wasm: { - execute: { - contract_addr: address, - funds: [], - msg: { - update_proposal_modules: { - to_add: [info], - to_disable: [], - }, - }, - }, - }, - }) - }, [config, depositInfo, depositInfoToken, singleChoiceProposal.prePropose]) - } - - // Disallow creation if: - // - multiple choice proposal module already exists - // - single-choice approval flow is enabled, since multiple choice doesn't - // support approval flow right now and that would be confusing. - const hideFromPicker = context.dao.proposalModules.some( - ({ contractName, prePropose }) => - DaoProposalMultipleAdapter.contractNames.some((name) => - contractName.includes(name) - ) || prePropose?.contractName === ContractName.PreProposeApprovalSingle - ) - - return { - key: ActionKey.EnableMultipleChoice, - Icon: NumbersEmoji, - label: t('title.enableMultipleChoiceProposals'), - description: t('info.enableMultipleChoiceProposalsDescription'), - notReusable: true, - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - hideFromPicker, - } -} diff --git a/packages/stateful/actions/core/dao_governance/ManageStorageItems/index.tsx b/packages/stateful/actions/core/dao_governance/ManageStorageItems/index.tsx deleted file mode 100644 index 5ac50b500..000000000 --- a/packages/stateful/actions/core/dao_governance/ManageStorageItems/index.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { useCallback } from 'react' - -import { DaoDaoCoreSelectors } from '@dao-dao/state' -import { WrenchEmoji, useCachedLoadingWithError } from '@dao-dao/stateless' -import { Feature } from '@dao-dao/types' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { makeWasmMessage, objectMatchesStructure } from '@dao-dao/utils' - -import { useActionOptions } from '../../../react' -import { - ManageStorageItemsData, - ManageStorageItemsComponent as StatelessManageStorageItemsComponent, -} from './Component' - -const useDefaults: UseDefaults = () => ({ - setting: true, - key: '', - value: '', -}) - -const Component: ActionComponent = ( - props -) => { - const { - address, - chain: { chain_id: chainId }, - } = useActionOptions() - - const existingItems = useCachedLoadingWithError( - DaoDaoCoreSelectors.listAllItemsSelector({ - contractAddress: address, - chainId, - }) - ) - - return ( - - ) -} - -export const makeManageStorageItemsAction: ActionMaker< - ManageStorageItemsData -> = ({ t, address, context }) => { - // Can only set items in a DAO. - if (context.type !== ActionContextType.Dao) { - return null - } - - const valueKey = context.dao.info.supportedFeatures[ - Feature.StorageItemValueKey - ] - ? 'value' - : 'addr' - - const useTransformToCosmos: UseTransformToCosmos< - ManageStorageItemsData - > = () => - useCallback( - ({ setting, key, value }: ManageStorageItemsData) => - makeWasmMessage({ - wasm: { - execute: { - contract_addr: address, - funds: [], - msg: setting - ? { - set_item: { - key, - [valueKey]: value, - }, - } - : { - remove_item: { - key, - }, - }, - }, - }, - }), - [] - ) - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - if ( - objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: {}, - }, - }, - }) && - msg.wasm.execute.contract_addr === address && - ('set_item' in msg.wasm.execute.msg || - 'remove_item' in msg.wasm.execute.msg) - ) { - const setting = 'set_item' in msg.wasm.execute.msg - - return { - match: true, - data: { - setting, - key: - (setting - ? msg.wasm.execute.msg.set_item.key - : msg.wasm.execute.msg.remove_item.key) ?? '', - value: setting ? msg.wasm.execute.msg.set_item[valueKey] : '', - }, - } - } - - return { match: false } - } - - return { - key: ActionKey.ManageStorageItems, - Icon: WrenchEmoji, - label: t('title.manageStorageItems'), - description: t('info.manageStorageItemsDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - } -} diff --git a/packages/stateful/actions/core/dao_governance/ManageSubDaoPause/index.tsx b/packages/stateful/actions/core/dao_governance/ManageSubDaoPause/index.tsx deleted file mode 100644 index 069a91f31..000000000 --- a/packages/stateful/actions/core/dao_governance/ManageSubDaoPause/index.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { useQueryClient } from '@tanstack/react-query' -import { useCallback } from 'react' - -import { daoQueries } from '@dao-dao/state' -import { PlayPauseEmoji } from '@dao-dao/stateless' -import { ChainId } from '@dao-dao/types' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { - NEUTRON_GOVERNANCE_DAO, - NEUTRON_SECURITY_SUBDAO, - NEUTRON_SUBDAO_CORE_CONTRACT_NAMES, - makeWasmMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { EntityDisplay } from '../../../../components' -import { useQueryLoadingData } from '../../../../hooks' -import { useMsgExecutesContract } from '../../../hooks' -import { ManageSubDaoPauseComponent, ManageSubDaoPauseData } from './Component' - -const useDefaults: UseDefaults = () => ({ - address: '', - pausing: true, - pauseBlocks: 0, -}) - -const useTransformToCosmos: UseTransformToCosmos = () => - useCallback( - ({ address, pausing, pauseBlocks }) => - makeWasmMessage({ - wasm: { - execute: { - contract_addr: address, - funds: [], - msg: pausing - ? { - pause: { - duration: pauseBlocks, - }, - } - : { - unpause: {}, - }, - }, - }, - }), - [] - ) - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - const isNeutronSubdaoWasmExecute = useMsgExecutesContract( - msg, - NEUTRON_SUBDAO_CORE_CONTRACT_NAMES - ) - - const isPause = - isNeutronSubdaoWasmExecute && - objectMatchesStructure(msg.wasm.execute.msg, { - pause: { - duration: {}, - }, - }) - const isUnpause = - isNeutronSubdaoWasmExecute && - objectMatchesStructure(msg.wasm.execute.msg, { - unpause: {}, - }) - - if (!isPause && !isUnpause) { - return { - match: false, - } - } - - return { - match: true, - data: { - address: msg.wasm.execute.contract_addr, - pausing: isPause, - pauseBlocks: isPause ? msg.wasm.execute.msg.pause.duration : 0, - }, - } -} - -const Component: ActionComponent = ( - props -) => { - const queryClient = useQueryClient() - const neutronSubdaos = useQueryLoadingData( - daoQueries.listAllSubDaos(queryClient, { - chainId: ChainId.NeutronMainnet, - address: NEUTRON_GOVERNANCE_DAO, - }), - [], - { - transform: (subDaos) => subDaos.map(({ addr }) => addr), - } - ) - - return ( - - ) -} - -export const makeManageSubDaoPauseAction: ActionMaker< - ManageSubDaoPauseData -> = ({ t, chain: { chain_id: chainId }, address, context }) => - chainId === ChainId.NeutronMainnet && - context.type === ActionContextType.Dao && - (address === NEUTRON_GOVERNANCE_DAO || address === NEUTRON_SECURITY_SUBDAO) - ? { - key: ActionKey.ManageSubDaoPause, - Icon: PlayPauseEmoji, - label: t('title.manageSubDaoPause'), - description: t('info.manageSubDaoPauseDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - } - : null diff --git a/packages/stateful/actions/core/dao_governance/ManageVetoableDaos/index.tsx b/packages/stateful/actions/core/dao_governance/ManageVetoableDaos/index.tsx deleted file mode 100644 index c7d08dd37..000000000 --- a/packages/stateful/actions/core/dao_governance/ManageVetoableDaos/index.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { useCallback } from 'react' - -import { daoVetoableDaosSelector } from '@dao-dao/state/recoil' -import { ThumbDownEmoji, useCachedLoadingWithError } from '@dao-dao/stateless' -import { Feature } from '@dao-dao/types' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { - VETOABLE_DAOS_ITEM_KEY_PREFIX, - makeWasmMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { AddressInput, EntityDisplay } from '../../../../components' -import { useActionOptions } from '../../../react' -import { - ManageVetoableDaosData, - ManageVetoableDaosComponent as StatelessManageVetoableDaosComponent, -} from './Component' - -const Component: ActionComponent = (props) => { - const { - address, - chain: { chain_id: chainId }, - } = useActionOptions() - - const currentlyEnabledLoading = useCachedLoadingWithError( - daoVetoableDaosSelector({ - chainId, - coreAddress: address, - }) - ) - - return ( - - ) -} - -export const makeManageVetoableDaosAction: ActionMaker< - ManageVetoableDaosData -> = ({ t, address, context, chain: { chain_id: chainId } }) => { - // Only DAOs. - if (context.type !== ActionContextType.Dao) { - return null - } - - const storageItemValueKey = context.dao.info.supportedFeatures[ - Feature.StorageItemValueKey - ] - ? 'value' - : 'addr' - - const useDefaults: UseDefaults = () => ({ - chainId, - address: '', - enable: true, - }) - - const useTransformToCosmos: UseTransformToCosmos< - ManageVetoableDaosData - > = () => - useCallback( - (data: ManageVetoableDaosData) => - makeWasmMessage({ - wasm: { - execute: { - contract_addr: address, - funds: [], - msg: data.enable - ? { - set_item: { - key: - VETOABLE_DAOS_ITEM_KEY_PREFIX + - data.chainId + - ':' + - data.address, - [storageItemValueKey]: '1', - }, - } - : { - remove_item: { - key: - VETOABLE_DAOS_ITEM_KEY_PREFIX + - data.chainId + - ':' + - data.address, - }, - }, - }, - }, - }), - [] - ) - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - if ( - objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: {}, - }, - }, - }) && - msg.wasm.execute.contract_addr === address && - ('set_item' in msg.wasm.execute.msg || - 'remove_item' in msg.wasm.execute.msg) - ) { - const enable = 'set_item' in msg.wasm.execute.msg - const key = - (enable - ? msg.wasm.execute.msg.set_item.key - : msg.wasm.execute.msg.remove_item.key) ?? '' - - const [chainId, address] = key - .replace(VETOABLE_DAOS_ITEM_KEY_PREFIX, '') - .split(':') - - return key.startsWith(VETOABLE_DAOS_ITEM_KEY_PREFIX) - ? { - match: true, - data: { - chainId, - address, - enable, - }, - } - : { - match: false, - } - } - - return { match: false } - } - - return { - key: ActionKey.ManageVetoableDaos, - Icon: ThumbDownEmoji, - label: t('title.manageVetoableDaos'), - description: t('info.manageVetoableDaosDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - } -} diff --git a/packages/stateful/actions/core/dao_governance/NeutronOverruleSubDaoProposal/index.tsx b/packages/stateful/actions/core/dao_governance/NeutronOverruleSubDaoProposal/index.tsx deleted file mode 100644 index 7082243d0..000000000 --- a/packages/stateful/actions/core/dao_governance/NeutronOverruleSubDaoProposal/index.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { useCallback } from 'react' - -import { NeutronCwdSubdaoTimelockSingleSelectors } from '@dao-dao/state' -import { ThumbDownEmoji, useCachedLoadingWithError } from '@dao-dao/stateless' -import { ChainId, ContractVersion, PreProposeModuleType } from '@dao-dao/types' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { ContractName } from '@dao-dao/utils' - -import { EntityDisplay, ProposalLine } from '../../../../components' -import { daoCoreProposalModulesSelector } from '../../../../recoil' -import { useMsgExecutesContract } from '../../../hooks' -import { - NeutronOverruleSubDaoProposalData, - NeutronOverruleSubDaoProposalComponent as StatelessNeutronOverruleSubDaoProposalComponent, -} from './Component' - -const useDefaults: UseDefaults = () => ({ - coreAddress: '', - proposalId: '', -}) - -const useTransformToCosmos: UseTransformToCosmos< - NeutronOverruleSubDaoProposalData -> = () => useCallback(() => undefined, []) - -const Component: ActionComponent< - undefined, - NeutronOverruleSubDaoProposalData -> = (props) => ( - -) - -export const makeNeutronOverruleSubDaoProposalAction: ActionMaker< - NeutronOverruleSubDaoProposalData -> = ({ t, chain: { chain_id: chainId }, context }) => { - // Only usable in Neutron DAOs. - if ( - chainId !== ChainId.NeutronMainnet || - context.type !== ActionContextType.Dao || - context.dao.coreVersion !== ContractVersion.V2AlphaNeutronFork - ) { - return null - } - - const useDecodedCosmosMsg: UseDecodedCosmosMsg< - NeutronOverruleSubDaoProposalData - > = (msg: Record) => { - const isNeutronOverrule = useMsgExecutesContract( - msg, - ContractName.NeutronCwdSubdaoTimelockSingle, - { - overrule_proposal: { - proposal_id: {}, - }, - } - ) - - const config = useCachedLoadingWithError( - isNeutronOverrule - ? NeutronCwdSubdaoTimelockSingleSelectors.configSelector({ - chainId, - contractAddress: msg.wasm.execute.contract_addr, - params: [], - }) - : undefined - ) - - // Get DAO proposal modules. - const proposalModules = useCachedLoadingWithError( - config.loading || config.errored - ? undefined - : daoCoreProposalModulesSelector({ - chainId, - coreAddress: config.data.subdao, - }) - ) - - // Get proposal module that uses the specified timelock address. - const proposalModule = - proposalModules.loading || proposalModules.errored - ? undefined - : proposalModules.data.find( - ({ prePropose }) => - prePropose?.type === PreProposeModuleType.NeutronSubdaoSingle && - prePropose.config.timelockAddress === - msg.wasm.execute.contract_addr - ) - - if (config.loading || config.errored || !proposalModule) { - return { - match: false, - } - } - - return { - match: true, - data: { - chainId, - coreAddress: config.data.subdao, - proposalId: - proposalModule.prefix + - msg.wasm.execute.msg.overrule_proposal.proposal_id, - }, - } - } - - return { - key: ActionKey.NeutronOverruleSubDaoProposal, - Icon: ThumbDownEmoji, - label: t('title.overruleSubDaoProposal'), - description: t('info.overruleSubDaoProposalDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - // Don't allow selecting in picker since Neutron fork DAO overrule proposals - // are automatically created. This is just an action to render them. - hideFromPicker: true, - } -} diff --git a/packages/stateful/actions/core/dao_governance/SetUpApprover/index.tsx b/packages/stateful/actions/core/dao_governance/SetUpApprover/index.tsx deleted file mode 100644 index 6b9f25605..000000000 --- a/packages/stateful/actions/core/dao_governance/SetUpApprover/index.tsx +++ /dev/null @@ -1,282 +0,0 @@ -import { useCallback, useEffect } from 'react' -import { useFormContext } from 'react-hook-form' - -import { - DaoDaoCoreSelectors, - DaoPreProposeApprovalSingleSelectors, - DaoProposalSingleCommonSelectors, -} from '@dao-dao/state/recoil' -import { - PersonRaisingHandEmoji, - useCachedLoading, - useCachedLoadingWithError, -} from '@dao-dao/stateless' -import { Feature, ModuleInstantiateInfo } from '@dao-dao/types' -import { - ActionChainContextType, - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { InstantiateMsg as DaoPreProposeApproverInstantiateMsg } from '@dao-dao/types/contracts/DaoPreProposeApprover' -import { InstantiateMsg as DaoProposalSingleInstantiateMsg } from '@dao-dao/types/contracts/DaoProposalSingle.v2' -import { - DaoProposalSingleAdapterId, - decodeJsonFromBase64, - encodeJsonToBase64, - makeWasmMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { EntityDisplay } from '../../../../components/EntityDisplay' -import { DaoProposalSingleAdapter } from '../../../../proposal-module-adapter/adapters/DaoProposalSingle' -import { useActionOptions } from '../../../react' -import { - SetUpApproverData, - SetUpApproverComponent as StatelessComponent, -} from './Component' - -const useDefaults: UseDefaults = () => ({ - address: '', -}) - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - if ( - !objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - update_proposal_modules: { - to_add: {}, - }, - }, - }, - }, - }) || - msg.wasm.execute.msg.update_proposal_modules.to_add.length !== 1 - ) { - return { - match: false, - } - } - - const info = msg.wasm.execute.msg.update_proposal_modules.to_add[0] - if ( - !objectMatchesStructure(info, { - admin: {}, - code_id: {}, - label: {}, - msg: {}, - }) - ) { - return { - match: false, - } - } - - const parsedMsg = decodeJsonFromBase64(info.msg, true) - if ( - !info.label.endsWith(`${DaoProposalSingleAdapterId}_approver`) || - !objectMatchesStructure(parsedMsg, { - pre_propose_info: { - module_may_propose: { - info: { - msg: {}, - }, - }, - }, - }) || - !parsedMsg.pre_propose_info.module_may_propose.info.label.includes( - 'approver' - ) - ) { - return { - match: false, - } - } - - const parsedPreProposeMsg = decodeJsonFromBase64( - parsedMsg.pre_propose_info.module_may_propose.info.msg, - true - ) - if ( - !objectMatchesStructure(parsedPreProposeMsg, { - pre_propose_approval_contract: {}, - }) - ) { - return { - match: false, - } - } - - return { - match: true, - data: { - address: parsedPreProposeMsg.pre_propose_approval_contract, - }, - } -} - -const Component: ActionComponent = (props) => { - const { - address, - chain: { chain_id: chainId }, - } = useActionOptions() - - const { watch, setValue } = useFormContext() - const preProposeApprovalSingle = watch( - (props.fieldNamePrefix + 'address') as 'address' - ) - // When creating, load DAO address from pre-propose module address. - const dao = useCachedLoading( - !props.isCreating && preProposeApprovalSingle - ? DaoPreProposeApprovalSingleSelectors.daoSelector({ - chainId, - contractAddress: preProposeApprovalSingle, - params: [], - }) - : undefined, - undefined - ) - useEffect(() => { - if (!props.isCreating && !dao.loading && dao.data) { - setValue((props.fieldNamePrefix + 'dao') as 'dao', dao.data) - } - }, [dao, props.fieldNamePrefix, props.isCreating, setValue]) - - const options = useCachedLoading( - DaoDaoCoreSelectors.approvalDaosSelector({ - chainId, - contractAddress: address, - }), - [] - ) - - return ( - - ) -} - -export const makeSetUpApproverAction: ActionMaker = ({ - t, - address, - context, - chain: { chain_id: chainId }, - chainContext, -}) => { - if ( - context.type !== ActionContextType.Dao || - !context.dao.info.supportedFeatures[Feature.Approval] || - // Type-check since we need code IDs, implied by DAO check. - chainContext.type !== ActionChainContextType.Supported - ) { - return null - } - - const useTransformToCosmos: UseTransformToCosmos = () => { - const singleChoiceProposal = context.dao.proposalModules.find( - ({ contractName }) => - DaoProposalSingleAdapter.contractNames.some((name) => - contractName.includes(name) - ) - ) - if (!singleChoiceProposal) { - throw new Error('No single choice proposal module found') - } - - const configLoading = useCachedLoadingWithError( - DaoProposalSingleCommonSelectors.configSelector({ - contractAddress: singleChoiceProposal.address, - chainId, - }) - ) - - return useCallback( - ({ address: preProposeApprovalContract }) => { - if (configLoading.loading) { - return - } - if (configLoading.errored) { - throw configLoading.error - } - const config = configLoading.data - - const info: ModuleInstantiateInfo = { - admin: { core_module: {} }, - code_id: chainContext.config.codeIds.DaoProposalSingle, - label: `dao-proposal-single_approver_${Date.now()}`, - msg: encodeJsonToBase64({ - threshold: config.threshold, - allow_revoting: config.allow_revoting, - close_proposal_on_execution_failure: - 'close_proposal_on_execution_failure' in config - ? config.close_proposal_on_execution_failure - : true, - min_voting_period: - 'min_voting_period' in config - ? config.min_voting_period - : undefined, - max_voting_period: config.max_voting_period, - only_members_execute: config.only_members_execute, - veto: 'veto' in config ? config.veto : undefined, - pre_propose_info: { - module_may_propose: { - info: { - admin: { core_module: {} }, - code_id: chainContext.config.codeIds.DaoPreProposeApprover, - label: `dao-pre-propose-approver_${Date.now()}`, - msg: encodeJsonToBase64({ - pre_propose_approval_contract: preProposeApprovalContract, - } as DaoPreProposeApproverInstantiateMsg), - funds: [], - }, - }, - }, - } as DaoProposalSingleInstantiateMsg), - funds: [], - } - - return makeWasmMessage({ - wasm: { - execute: { - contract_addr: address, - funds: [], - msg: { - update_proposal_modules: { - to_add: [info], - to_disable: [], - }, - }, - }, - }, - }) - }, - [configLoading] - ) - } - - return { - key: ActionKey.SetUpApprover, - Icon: PersonRaisingHandEmoji, - label: t('title.setUpApprover'), - description: t('info.setUpApproverDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - } -} diff --git a/packages/stateful/actions/core/dao_governance/UpdatePreProposeConfig/index.tsx b/packages/stateful/actions/core/dao_governance/UpdatePreProposeConfig/index.tsx deleted file mode 100644 index 55a0585a1..000000000 --- a/packages/stateful/actions/core/dao_governance/UpdatePreProposeConfig/index.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { useMemo } from 'react' - -import { BallotDepositEmoji, useDaoContext } from '@dao-dao/stateless' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types' - -import { SuspenseLoader, Trans } from '../../../../components' -import { matchAndLoadCommon } from '../../../../proposal-module-adapter' -import { useActionOptions } from '../../../react' -import { - ProposalModuleWithAction, - UpdatePreProposeConfigComponent, - UpdatePreProposeConfigData, -} from './Component' - -const useUpdatePreProposeConfigActions = (): ProposalModuleWithAction[] => { - const options = useActionOptions() - const { dao } = useDaoContext() - - const proposalModuleActions = useMemo( - () => - dao.info.proposalModules - .flatMap((proposalModule): ProposalModuleWithAction | [] => { - const action = matchAndLoadCommon( - dao, - proposalModule.address - ).fields.updatePreProposeConfigActionMaker?.(options) - - return action - ? { - proposalModule, - action, - } - : [] - }) - // Sort proposal modules by prefix. - .sort((a, b) => - a.proposalModule.prefix.localeCompare(b.proposalModule.prefix) - ), - [dao, options] - ) - - return proposalModuleActions -} - -const Component: ActionComponent = (props) => { - const options = useUpdatePreProposeConfigActions() - // Proposal modules should never change, so it should be safe to call hooks - // in a loop here. - const defaults = options.reduce( - (acc, { proposalModule, action }) => ({ - ...acc, - [proposalModule.address]: action.useDefaults(), - }), - {} as Record> - ) - - return ( - - ) -} - -export const makeUpdatePreProposeConfigAction: ActionMaker< - UpdatePreProposeConfigData -> = ({ context, t }) => { - if (context.type !== ActionContextType.Dao) { - return null - } - - const useDefaults: UseDefaults = () => { - const actions = useUpdatePreProposeConfigActions() - // Proposal modules should never change, so it should be safe to call hooks - // in a loop here. - const defaults = actions.map(({ action }) => action.useDefaults()) - - return { - proposalModuleAddress: actions[0]?.proposalModule.address ?? '', - data: defaults[0] ?? {}, - } - } - - const useTransformToCosmos: UseTransformToCosmos< - UpdatePreProposeConfigData - > = () => { - const actions = useUpdatePreProposeConfigActions() - // Proposal modules should never change, so it should be safe to call hooks - // in a loop here. - const transforms = actions.map(({ action, ...props }) => ({ - ...props, - transform: action.useTransformToCosmos(), - })) - - return ({ proposalModuleAddress, data }) => { - const transform = transforms.find( - ({ proposalModule }) => proposalModule.address === proposalModuleAddress - )?.transform - if (!transform) { - throw new Error(t('error.failedToFindMatchingProposalModule')) - } - - return transform(data) - } - } - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg - ) => { - const actions = useUpdatePreProposeConfigActions() - // Proposal modules should never change, so it should be safe to call hooks - // in a loop here. - const decodes = actions.map(({ action, ...props }) => ({ - ...props, - decoded: action.useDecodedCosmosMsg(msg), - })) - - const matchingDecode = decodes.find(({ decoded }) => decoded.match) - - return matchingDecode?.decoded.match - ? { - match: true, - data: { - proposalModuleAddress: matchingDecode.proposalModule.address, - data: matchingDecode.decoded.data, - }, - } - : { - match: false, - } - } - - return { - key: ActionKey.UpdatePreProposeConfig, - Icon: BallotDepositEmoji, - label: t('form.updateProposalSubmissionConfigTitle'), - description: t('info.updateProposalSubmissionConfigActionDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - } -} diff --git a/packages/stateful/actions/core/dao_governance/UpdateProposalConfig/index.tsx b/packages/stateful/actions/core/dao_governance/UpdateProposalConfig/index.tsx deleted file mode 100644 index 29f70d206..000000000 --- a/packages/stateful/actions/core/dao_governance/UpdateProposalConfig/index.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { useMemo } from 'react' - -import { BallotDepositEmoji, useDaoContext } from '@dao-dao/stateless' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types' - -import { SuspenseLoader, Trans } from '../../../../components' -import { matchAndLoadCommon } from '../../../../proposal-module-adapter' -import { useActionOptions } from '../../../react' -import { - ProposalModuleWithAction, - UpdateProposalConfigComponent, - UpdateProposalConfigData, -} from './Component' - -const useUpdateProposalConfigActions = (): ProposalModuleWithAction[] => { - const options = useActionOptions() - const { dao } = useDaoContext() - - const proposalModuleActions = useMemo( - () => - dao.info.proposalModules - .flatMap((proposalModule): ProposalModuleWithAction | [] => { - const action = matchAndLoadCommon( - dao, - proposalModule.address - ).fields.updateConfigActionMaker(options) - - return action - ? { - proposalModule, - action, - } - : [] - }) - // Sort proposal modules by prefix. - .sort((a, b) => - a.proposalModule.prefix.localeCompare(b.proposalModule.prefix) - ), - [dao, options] - ) - - return proposalModuleActions -} - -const Component: ActionComponent = (props) => { - const options = useUpdateProposalConfigActions() - // Proposal modules should never change, so it should be safe to call hooks - // in a loop here. - const defaults = options.reduce( - (acc, { proposalModule, action }) => ({ - ...acc, - [proposalModule.address]: action.useDefaults(), - }), - {} as Record> - ) - - return ( - - ) -} - -export const makeUpdateProposalConfigAction: ActionMaker< - UpdateProposalConfigData -> = ({ context, t }) => { - if (context.type !== ActionContextType.Dao) { - return null - } - - const useDefaults: UseDefaults = () => { - const actions = useUpdateProposalConfigActions() - // Proposal modules should never change, so it should be safe to call hooks - // in a loop here. - const defaults = actions.map(({ action }) => action.useDefaults()) - - return { - proposalModuleAddress: actions[0]?.proposalModule.address ?? '', - data: defaults[0] ?? {}, - } - } - - const useTransformToCosmos: UseTransformToCosmos< - UpdateProposalConfigData - > = () => { - const actions = useUpdateProposalConfigActions() - // Proposal modules should never change, so it should be safe to call hooks - // in a loop here. - const transforms = actions.map(({ action, ...props }) => ({ - ...props, - transform: action.useTransformToCosmos(), - })) - - return ({ proposalModuleAddress, data }) => { - const transform = transforms.find( - ({ proposalModule }) => proposalModule.address === proposalModuleAddress - )?.transform - if (!transform) { - throw new Error(t('error.failedToFindMatchingProposalModule')) - } - - return transform(data) - } - } - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg - ) => { - const actions = useUpdateProposalConfigActions() - // Proposal modules should never change, so it should be safe to call hooks - // in a loop here. - const decodes = actions.map(({ action, ...props }) => ({ - ...props, - decoded: action.useDecodedCosmosMsg(msg), - })) - - const matchingDecode = decodes.find(({ decoded }) => decoded.match) - - return matchingDecode?.decoded.match - ? { - match: true, - data: { - proposalModuleAddress: matchingDecode.proposalModule.address, - data: matchingDecode.decoded.data, - }, - } - : { - match: false, - } - } - - return { - key: ActionKey.UpdateProposalConfig, - Icon: BallotDepositEmoji, - label: t('form.updateVotingConfigTitle'), - description: t('info.updateVotingConfigActionDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - } -} diff --git a/packages/stateful/actions/core/dao_governance/UpgradeV1ToV2/index.tsx b/packages/stateful/actions/core/dao_governance/UpgradeV1ToV2/index.tsx deleted file mode 100644 index 8073ad232..000000000 --- a/packages/stateful/actions/core/dao_governance/UpgradeV1ToV2/index.tsx +++ /dev/null @@ -1,368 +0,0 @@ -import { useQueryClient } from '@tanstack/react-query' -import { useCallback, useMemo } from 'react' -import { useRecoilValueLoadable, waitForAll } from 'recoil' - -import { daoPotentialSubDaosSelector } from '@dao-dao/state/recoil' -import { - Loader, - UnicornEmoji, - useCachedLoadable, - useCachedLoading, - useLoadingPromise, -} from '@dao-dao/stateless' -import { - ActionChainContextType, - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - ContractVersion, - IDaoBase, - LoadingData, - UseDecodedCosmosMsg, - UseDefaults, - UseHideFromPicker, - UseTransformToCosmos, -} from '@dao-dao/types' -import { PreProposeInfo } from '@dao-dao/types/contracts/DaoProposalSingle.v2' -import { - encodeJsonToBase64, - makeWasmMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { getDao } from '../../../../clients' -import { AddressInput, EntityDisplay } from '../../../../components' -import { matchAndLoadCommon } from '../../../../proposal-module-adapter' -import { useActionOptions } from '../../../react' -import { UpgradeV1ToV2Component, UpgradeV1ToV2Data } from './Component' - -const useV1SubDaos = () => { - const { - address, - chain: { chain_id: chainId }, - } = useActionOptions() - - const potentialSubDaos = useRecoilValueLoadable( - daoPotentialSubDaosSelector({ - coreAddress: address, - chainId, - }) - ) - - const queryClient = useQueryClient() - const daos = useLoadingPromise({ - promise: - potentialSubDaos.state !== 'hasValue' - ? undefined - : async () => - ( - await Promise.allSettled( - potentialSubDaos.contents.map(async (potentialSubDao) => { - const dao = getDao({ - queryClient, - chainId, - coreAddress: potentialSubDao, - }) - await dao.init() - return dao - }) - ) - ).flatMap((l) => (l.status === 'fulfilled' ? l.value : [])), - // Reload when query client, chain ID, or potentialSubDaos changes. - deps: [queryClient, chainId, potentialSubDaos], - }) - - const potentialV1SubDaos: LoadingData = useMemo( - () => - !daos.loading - ? { - loading: false, - data: daos.errored - ? [] - : daos.data.filter( - (dao) => dao.info.coreVersion === ContractVersion.V1 - ), - } - : { - loading: true, - }, - [daos] - ) - - return potentialV1SubDaos -} - -const Component: ActionComponent = (props) => { - const v1SubDaos = useV1SubDaos() - const { address, context } = useActionOptions() - - return v1SubDaos.loading ? ( - - ) : ( - - ) -} - -export const makeUpgradeV1ToV2Action: ActionMaker = ({ - context, - t, - address, - chain, - chainContext, -}) => { - if ( - context.type !== ActionContextType.Dao || - // If no DAO migrator, don't show upgrade action. - chainContext.type !== ActionChainContextType.Supported || - chainContext.config.codeIds.DaoMigrator <= 0 - ) { - return null - } - - const { codeIds } = chainContext.config - - const useDefaults: UseDefaults = () => { - // Load sub DAOs for registering as the current DAO upgrades to v2. If this - // DAO is not on v1, there are no SubDAOs to load. - const potentialSubDaos = useCachedLoading( - context.dao.coreVersion === ContractVersion.V1 - ? daoPotentialSubDaosSelector({ - coreAddress: address, - chainId: chain.chain_id, - }) - : undefined, - [] - ) - - return { - targetAddress: - // If DAO is not on v1, don't default to the DAO address. - context.dao.coreVersion === ContractVersion.V1 ? address : '', - subDaos: !potentialSubDaos.loading - ? potentialSubDaos.data.map((addr) => ({ - addr, - })) - : [], - } - } - - const useTransformToCosmos: UseTransformToCosmos = () => { - const v1SubDaos = useV1SubDaos() - - // Get proposal module deposit info to pass through to pre-propose. - const depositInfoSelectors = v1SubDaos.loading - ? [] - : v1SubDaos.data.map((dao) => - dao.proposalModules.map( - (proposalModule) => - matchAndLoadCommon(dao, proposalModule.address).selectors - .depositInfo - ) - ) - // The deposit infos are ordered to match the proposal modules in the DAO - // core list, which is what the migration contract expects. - const proposalModuleDepositInfosLoadable = useCachedLoadable( - depositInfoSelectors - ? waitForAll( - depositInfoSelectors.map((selectors) => waitForAll(selectors)) - ) - : undefined - ) - - return useCallback( - ({ targetAddress, subDaos }) => { - if (proposalModuleDepositInfosLoadable.state === 'hasError') { - throw proposalModuleDepositInfosLoadable.contents - } - - if ( - v1SubDaos.loading || - v1SubDaos.updating || - proposalModuleDepositInfosLoadable.state === 'loading' || - proposalModuleDepositInfosLoadable.updating - ) { - return - } - - // Get proposal module deposit infos for the target DAO based on the - // index of the address in the available DAOs list. - const targetDaoIndex = v1SubDaos.data.findIndex( - ({ coreAddress }) => coreAddress === targetAddress - ) - if (targetDaoIndex === -1) { - throw new Error(t('error.loadingData')) - } - - const { proposalModules } = v1SubDaos.data[targetDaoIndex].info - const proposalModuleDepositInfos = - proposalModuleDepositInfosLoadable.contents[targetDaoIndex] - - // Array of tuples of each proposal module address and its params. - const proposalParams = proposalModuleDepositInfos.map( - (depositInfo, index) => [ - proposalModules[index].address, - { - close_proposal_on_execution_failure: true, - pre_propose_info: { - module_may_propose: { - info: { - admin: { core_module: {} }, - code_id: codeIds.DaoPreProposeSingle, - label: `dao-pre-propose-single_${index}_${Date.now()}`, - funds: [], - msg: encodeJsonToBase64({ - deposit_info: depositInfo - ? { - amount: depositInfo.amount, - denom: { - token: { - denom: depositInfo.denom, - }, - }, - refund_policy: depositInfo.refund_policy, - } - : null, - extension: {}, - open_proposal_submission: false, - }), - }, - }, - }, - }, - ] - ) as [ - string, - { - close_proposal_on_execution_failure: boolean - pre_propose_info: PreProposeInfo - } - ][] - - return makeWasmMessage({ - wasm: { - migrate: { - contract_addr: targetAddress, - new_code_id: codeIds.DaoCore, - msg: { - from_v1: { - dao_uri: `https://daodao.zone/dao/${targetAddress}`, - params: { - migrator_code_id: codeIds.DaoMigrator, - params: { - sub_daos: subDaos, - migration_params: { - migrate_stake_cw20_manager: true, - proposal_params: proposalParams, - }, - v1_code_ids: { - proposal_single: 427, - cw4_voting: 429, - cw20_stake: 430, - cw20_staked_balances_voting: 431, - }, - v2_code_ids: { - proposal_single: codeIds.DaoProposalSingle, - cw4_voting: codeIds.DaoVotingCw4, - cw20_stake: codeIds.Cw20Stake ?? -1, - cw20_staked_balances_voting: - codeIds.DaoVotingCw20Staked ?? -1, - }, - }, - }, - }, - }, - }, - }, - }) - }, - [v1SubDaos, proposalModuleDepositInfosLoadable] - ) - } - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = (msg) => - objectMatchesStructure(msg, { - wasm: { - migrate: { - contract_addr: {}, - new_code_id: {}, - msg: { - from_v1: { - dao_uri: {}, - params: { - migrator_code_id: {}, - params: { - sub_daos: {}, - migration_params: { - migrate_stake_cw20_manager: {}, - proposal_params: {}, - }, - v1_code_ids: { - proposal_single: {}, - cw4_voting: {}, - cw20_stake: {}, - cw20_staked_balances_voting: {}, - }, - v2_code_ids: { - proposal_single: {}, - cw4_voting: {}, - cw20_stake: {}, - cw20_staked_balances_voting: {}, - }, - }, - }, - }, - }, - }, - }, - }) - ? { - match: true, - data: { - targetAddress: msg.wasm.migrate.contract_addr, - subDaos: msg.wasm.migrate.msg.from_v1.params.params.sub_daos, - }, - } - : { - match: false, - } - - // Hide from picker if the current DAO is not on v1 and there are no SubDAOs - // on v1. Thus, there is nothing to upgrade. - const useHideFromPicker: UseHideFromPicker = () => { - const v1SubDaos = useV1SubDaos() - - return ( - context.dao.coreVersion !== ContractVersion.V1 || - v1SubDaos.loading || - v1SubDaos.data.length === 0 - ) - } - - return { - key: ActionKey.UpgradeV1ToV2, - Icon: UnicornEmoji, - label: t('title.upgradeToV2'), - description: t('info.upgradeToV2Description'), - notReusable: true, - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - useHideFromPicker, - } -} diff --git a/packages/stateful/actions/core/dao_governance/VetoOrEarlyExecuteDaoProposal/index.tsx b/packages/stateful/actions/core/dao_governance/VetoOrEarlyExecuteDaoProposal/index.tsx deleted file mode 100644 index 0d92ea318..000000000 --- a/packages/stateful/actions/core/dao_governance/VetoOrEarlyExecuteDaoProposal/index.tsx +++ /dev/null @@ -1,386 +0,0 @@ -import { useQueryClient } from '@tanstack/react-query' -import { useCallback, useEffect } from 'react' -import { useFormContext } from 'react-hook-form' - -import { DaoProposalCommonSelectors, isContractSelector } from '@dao-dao/state' -import { - ControlKnobsEmoji, - useCachedLoadingWithError, -} from '@dao-dao/stateless' -import { - ActionComponent, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { - ContractName, - decodeCw1WhitelistExecuteMsg, - decodePolytoneExecuteMsg, - getChainAddressForActionOptions, - makeCw1WhitelistExecuteMessage, - makeExecuteSmartContractMessage, - maybeMakePolytoneExecuteMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { - AddressInput, - EntityDisplay, - ProposalLine, -} from '../../../../components' -import { useQueryLoadingDataWithError } from '../../../../hooks' -import { daoQueries } from '../../../../queries/dao' -import { daosWithVetoableProposalsSelector } from '../../../../recoil' -import { useActionOptions } from '../../../react' -import { - VetoOrEarlyExecuteDaoProposalComponent as StatelessVetoOrEarlyExecuteDaoProposalComponent, - VetoOrEarlyExecuteDaoProposalData, -} from './Component' - -const Component: ActionComponent< - undefined, - VetoOrEarlyExecuteDaoProposalData -> = (props) => { - const { isCreating, fieldNamePrefix } = props - const { - chain: { chain_id: daoChainId }, - address, - } = useActionOptions() - const { watch, setValue } = - useFormContext() - - const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') - const coreAddress = watch( - (props.fieldNamePrefix + 'coreAddress') as 'coreAddress' - ) - const proposalModuleAddress = watch( - (props.fieldNamePrefix + 'proposalModuleAddress') as 'proposalModuleAddress' - ) - const proposalId = watch( - (props.fieldNamePrefix + 'proposalId') as 'proposalId' - ) - - const daoVetoableProposals = useCachedLoadingWithError( - daosWithVetoableProposalsSelector({ - chainId: daoChainId, - coreAddress: address, - // Include even those not registered in the DAO's list. - includeAll: true, - }) - ) - - // If no DAO selected, autoselect first one. - useEffect(() => { - if ( - !isCreating || - (chainId && coreAddress) || - daoVetoableProposals.loading || - daoVetoableProposals.errored || - daoVetoableProposals.data.length === 0 - ) { - return - } - - setValue( - (fieldNamePrefix + 'chainId') as 'chainId', - daoVetoableProposals.data[0].chainId - ) - setValue( - (fieldNamePrefix + 'coreAddress') as 'coreAddress', - daoVetoableProposals.data[0].dao - ) - }, [ - chainId, - coreAddress, - daoVetoableProposals, - fieldNamePrefix, - isCreating, - setValue, - ]) - - const queryClient = useQueryClient() - const selectedDaoInfo = useQueryLoadingDataWithError( - daoQueries.info( - queryClient, - chainId && coreAddress - ? { - chainId, - coreAddress, - } - : undefined - ) - ) - - // Select first proposal once loaded if nothing selected. - useEffect(() => { - if ( - isCreating && - !daoVetoableProposals.loading && - !daoVetoableProposals.errored && - !proposalId && - daoVetoableProposals.data.length > 0 - ) { - setValue( - (fieldNamePrefix + 'chainId') as 'chainId', - daoVetoableProposals.data[0].chainId - ) - setValue( - (fieldNamePrefix + 'coreAddress') as 'coreAddress', - daoVetoableProposals.data[0].dao - ) - setValue( - (fieldNamePrefix + 'proposalModuleAddress') as 'proposalModuleAddress', - daoVetoableProposals.data[0].proposalsWithModule[0].proposalModule - .address - ) - setValue( - (fieldNamePrefix + 'proposalId') as 'proposalId', - daoVetoableProposals.data[0].proposalsWithModule[0].proposals[0].id - ) - } - }, [isCreating, proposalId, setValue, fieldNamePrefix, daoVetoableProposals]) - - // Load cw1-whitelist vetoer for proposal. - const proposalLoading = useCachedLoadingWithError( - proposalModuleAddress && !isNaN(proposalId) && proposalId > -1 - ? DaoProposalCommonSelectors.proposalSelector({ - chainId, - contractAddress: proposalModuleAddress, - params: [ - { - proposalId, - }, - ], - }) - : undefined - ) - const isCw1WhitelistLoading = useCachedLoadingWithError( - !proposalLoading.loading && - !proposalLoading.errored && - proposalLoading.data.proposal.veto?.vetoer - ? isContractSelector({ - chainId, - contractAddress: proposalLoading.data.proposal.veto.vetoer, - name: ContractName.Cw1Whitelist, - }) - : undefined - ) - useEffect(() => { - if ( - !proposalLoading.loading && - !proposalLoading.errored && - !isCw1WhitelistLoading.loading && - !isCw1WhitelistLoading.errored && - isCw1WhitelistLoading.data - ) { - setValue( - (fieldNamePrefix + 'cw1WhitelistVetoer') as 'cw1WhitelistVetoer', - isCw1WhitelistLoading.data - ? proposalLoading.data.proposal.veto?.vetoer - : undefined - ) - } - }, [fieldNamePrefix, isCw1WhitelistLoading, proposalLoading, setValue]) - - return ( - - ) -} - -export const makeVetoOrEarlyExecuteDaoProposalAction: ActionMaker< - VetoOrEarlyExecuteDaoProposalData -> = (options) => { - const { - t, - chain: { chain_id: currentChainId }, - address, - } = options - - const useDefaults: UseDefaults = () => ({ - chainId: currentChainId, - coreAddress: '', - proposalModuleAddress: '', - proposalId: -1, - action: 'veto', - vetoerIsCw1Whitelist: false, - }) - - const useTransformToCosmos: UseTransformToCosmos< - VetoOrEarlyExecuteDaoProposalData - > = () => - useCallback( - ({ - chainId, - proposalModuleAddress, - proposalId, - action, - cw1WhitelistVetoer, - }) => { - const actionSender = - getChainAddressForActionOptions(options, chainId) || '' - - const msg = makeExecuteSmartContractMessage({ - chainId, - sender: cw1WhitelistVetoer || actionSender, - contractAddress: proposalModuleAddress, - msg: { - [action === 'veto' ? 'veto' : 'execute']: { - proposal_id: proposalId, - }, - }, - }) - - return maybeMakePolytoneExecuteMessage( - currentChainId, - chainId, - cw1WhitelistVetoer - ? makeCw1WhitelistExecuteMessage({ - chainId, - sender: actionSender, - cw1WhitelistContract: cw1WhitelistVetoer, - msg, - }) - : msg - ) - }, - [] - ) - - const useDecodedCosmosMsg: UseDecodedCosmosMsg< - VetoOrEarlyExecuteDaoProposalData - > = (msg: Record) => { - let chainId = currentChainId - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - } - - // If this is a cw1-whitelist execute msg, check msg inside of it. - const decodedCw1Whitelist = decodeCw1WhitelistExecuteMsg(msg, 'one') - if (decodedCw1Whitelist) { - msg = decodedCw1Whitelist.msgs[0] - } - - const isWasmExecuteMessage = objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: {}, - }, - }, - }) - - const isVeto = - isWasmExecuteMessage && - objectMatchesStructure(msg.wasm.execute.msg, { - veto: { - proposal_id: {}, - }, - }) - const isExecute = - isWasmExecuteMessage && - objectMatchesStructure(msg.wasm.execute.msg, { - execute: { - proposal_id: {}, - }, - }) - - const proposalId = isVeto - ? msg.wasm.execute.msg.veto.proposal_id - : isExecute - ? msg.wasm.execute.msg.execute.proposal_id - : -1 - - // Get DAO that this proposal module is attached to. - const daoLoading = useCachedLoadingWithError( - isWasmExecuteMessage - ? DaoProposalCommonSelectors.daoSelector({ - chainId, - contractAddress: msg.wasm.execute.contract_addr, - }) - : undefined - ) - - const proposalLoading = useCachedLoadingWithError( - isWasmExecuteMessage - ? DaoProposalCommonSelectors.proposalSelector({ - chainId, - contractAddress: msg.wasm.execute.contract_addr, - params: [ - { - proposalId, - }, - ], - }) - : undefined - ) - - const isCw1WhitelistLoading = useCachedLoadingWithError( - !proposalLoading.loading && - !proposalLoading.errored && - proposalLoading.data.proposal.veto?.vetoer - ? isContractSelector({ - chainId, - contractAddress: proposalLoading.data.proposal.veto.vetoer, - name: ContractName.Cw1Whitelist, - }) - : undefined - ) - - if ( - daoLoading.loading || - daoLoading.errored || - proposalLoading.loading || - proposalLoading.errored || - isCw1WhitelistLoading.loading || - isCw1WhitelistLoading.errored || - (!isVeto && - (!isExecute || - // If executing, it's not an early-execute if we are not the vetoer. - proposalLoading.data.proposal.veto?.vetoer !== address)) - ) { - return { - match: false, - } - } - - return { - match: true, - data: { - chainId, - coreAddress: daoLoading.data, - proposalModuleAddress: msg.wasm.execute.contract_addr, - proposalId, - action: isVeto ? 'veto' : 'earlyExecute', - cw1WhitelistVetoer: isCw1WhitelistLoading.data - ? proposalLoading.data.proposal.veto?.vetoer - : undefined, - }, - } - } - - return { - key: ActionKey.VetoOrEarlyExecuteDaoProposal, - Icon: ControlKnobsEmoji, - label: t('title.vetoOrEarlyExecute'), - description: t('info.vetoOrEarlyExecuteDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - } -} diff --git a/packages/stateful/actions/core/dao_governance/index.ts b/packages/stateful/actions/core/dao_governance/index.ts deleted file mode 100644 index ad16d2932..000000000 --- a/packages/stateful/actions/core/dao_governance/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ActionCategoryKey, ActionCategoryMaker } from '@dao-dao/types' - -import { makeCreateCrossChainAccountAction } from './CreateCrossChainAccount' -import { makeCreateDaoAction } from './CreateDao' -import { makeDaoAdminExecAction } from './DaoAdminExec' -import { makeEnableMultipleChoiceAction } from './EnableMultipleChoice' -import { makeManageStorageItemsAction } from './ManageStorageItems' -import { makeManageSubDaoPauseAction } from './ManageSubDaoPause' -import { makeManageVetoableDaosAction } from './ManageVetoableDaos' -import { makeNeutronOverruleSubDaoProposalAction } from './NeutronOverruleSubDaoProposal' -import { makeSetUpApproverAction } from './SetUpApprover' -import { makeUpdatePreProposeConfigAction } from './UpdatePreProposeConfig' -import { makeUpdateProposalConfigAction } from './UpdateProposalConfig' -import { makeUpgradeV1ToV2Action } from './UpgradeV1ToV2' -import { makeVetoOrEarlyExecuteDaoProposalAction } from './VetoOrEarlyExecuteDaoProposal' - -export const makeDaoGovernanceActionCategory: ActionCategoryMaker = ({ - t, - context, -}) => ({ - key: ActionCategoryKey.DaoGovernance, - label: t('actionCategory.daoGovernanceLabel'), - description: t('actionCategory.daoGovernanceDescription', { - context: context.type, - }), - actionMakers: [ - makeEnableMultipleChoiceAction, - makeManageStorageItemsAction, - makeDaoAdminExecAction, - makeUpgradeV1ToV2Action, - makeCreateCrossChainAccountAction, - makeSetUpApproverAction, - makeVetoOrEarlyExecuteDaoProposalAction, - makeManageVetoableDaosAction, - makeManageSubDaoPauseAction, - makeNeutronOverruleSubDaoProposalAction, - makeUpdateProposalConfigAction, - makeUpdatePreProposeConfigAction, - makeCreateDaoAction, - ], -}) diff --git a/packages/stateful/actions/core/index.ts b/packages/stateful/actions/core/index.ts index eff4bd6dc..78f3587fc 100644 --- a/packages/stateful/actions/core/index.ts +++ b/packages/stateful/actions/core/index.ts @@ -1,39 +1,42 @@ import { - Action, ActionCategory, + ActionCategoryBase, ActionCategoryMaker, - ActionCategoryWithLabel, ActionOptions, + ImplementedAction, } from '@dao-dao/types/actions' -import { DISABLED_ACTIONS } from '@dao-dao/utils' -import { makeAdvancedActionCategory } from './advanced' -import { makeAuthorizationsActionCategory } from './authorizations' -import { makeChainGovernanceActionCategory } from './chain_governance' -import { makeCommonlyUsedCategory } from './commonlyUsed' -import { makeDaoAppearanceActionCategory } from './dao_appearance' -import { makeDaoGovernanceActionCategory } from './dao_governance' -import { makeManageNftsActionCategory } from './nfts' -import { makeSmartContractingActionCategory } from './smart_contracting' -import { makeSubDaosActionCategory } from './subdaos' -import { makeTreasuryActionCategory } from './treasury' -import { makeValenceActionCategory } from './valence' +import * as actions from './actions' +import * as categories from './categories' -// Get all core action category makers. -export const getCoreActionCategoryMakers = (): ActionCategoryMaker[] => [ - makeCommonlyUsedCategory, - makeTreasuryActionCategory, - makeDaoGovernanceActionCategory, - makeSubDaosActionCategory, - makeDaoAppearanceActionCategory, - makeManageNftsActionCategory, - makeSmartContractingActionCategory, - makeAuthorizationsActionCategory, - makeChainGovernanceActionCategory, - makeValenceActionCategory, - makeAdvancedActionCategory, - // Add action category makers here to display them. -] +// Get all core actions, preserving instance to prevent unnecessary re-renders +// in React hooks. +let _coreActions: ImplementedAction[] | null = null +export const getCoreActions = (): ImplementedAction[] => { + _coreActions ??= Object.values(actions) + return _coreActions +} + +// Get all core action category makers, preserving instance to prevent +// unnecessary re-renders in React hooks. +let _coreActionCategoryMakers: ActionCategoryMaker[] | null = null +export const getCoreActionCategoryMakers = (): ActionCategoryMaker[] => { + // Set order explicitly instead of relying on import order. + _coreActionCategoryMakers ??= [ + categories.makeCommonlyUsedCategory, + categories.makeTreasuryActionCategory, + categories.makeDaoGovernanceActionCategory, + categories.makeSubDaosActionCategory, + categories.makeDaoAppearanceActionCategory, + categories.makeManageNftsActionCategory, + categories.makeSmartContractingActionCategory, + categories.makeAuthorizationsActionCategory, + categories.makeChainGovernanceActionCategory, + categories.makeValenceActionCategory, + categories.makeAdvancedActionCategory, + ] + return _coreActionCategoryMakers +} // Make action category with given options, processing the action category and // action makers and removing disabled actions. Returns null if the maker @@ -42,57 +45,29 @@ export const getCoreActionCategoryMakers = (): ActionCategoryMaker[] => [ export const makeActionCategory = ( maker: ActionCategoryMaker, options: ActionOptions -): ActionCategory | null => { +): ActionCategoryBase | null => { const category = maker(options) // Ignore category if the maker returns null, meaning its invalid for the - // given options context. - if (!category) { + // given options context, or if it has no actions. + if (!category || category.actionKeys.length === 0) { return null } - const { key, label, description, actions: _actions, actionMakers } = category - - const actions = [ - ...(_actions ?? []), - // Make actions. - ...(actionMakers ?? []) - .map((makeAction) => makeAction(options)) - .filter( - (action): action is Action => - // Remove null values, since maker functions return null if - // they don't make sense in the context (like a DAO-only - // action in a wallet context). - action !== null && - // Remove disabled actions. - !DISABLED_ACTIONS.includes(action.key) - ), - ] - - // Ignore category if it has no actions. - if (actions.length === 0) { - return null - } - - return { - key, - label, - description, - actions, - } + return category } // Make action categories from makers with given options, merging categories // with the same key, and sorting alphabetically. Returns only categories with // a label and at least one action. -export const makeActionCategoriesWithLabel = ( +export const makeActionCategories = ( makers: ActionCategoryMaker[], options: ActionOptions -): ActionCategoryWithLabel[] => +): ActionCategory[] => makers .map((maker) => makeActionCategory(maker, options)) .filter( - (category): category is ActionCategory => + (category): category is ActionCategoryBase => // Remove null values, since maker functions return null if they don't // make sense in the context (like a DAO-only action in a wallet // context) or have no actions. @@ -105,7 +80,7 @@ export const makeActionCategoriesWithLabel = ( if (existing) { // Merge actions. - existing.actions = [...existing.actions, ...category.actions] + existing.actionKeys = [...existing.actionKeys, ...category.actionKeys] // Update label and description if they're not defined. existing.label ||= category.label existing.description ||= category.description @@ -121,6 +96,6 @@ export const makeActionCategoriesWithLabel = ( } return acc - }, [] as ActionCategory[]) + }, [] as ActionCategoryBase[]) // Remove categories with no label, just a type-check post-merge. - .filter((category): category is ActionCategoryWithLabel => !!category.label) + .filter((category): category is ActionCategory => !!category.label) diff --git a/packages/stateful/actions/core/nfts/CreateNftCollection/index.tsx b/packages/stateful/actions/core/nfts/CreateNftCollection/index.tsx deleted file mode 100644 index 0ee634ea7..000000000 --- a/packages/stateful/actions/core/nfts/CreateNftCollection/index.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { useCallback } from 'react' - -import { - ArtistPaletteEmoji, - DaoSupportedChainPickerInput, -} from '@dao-dao/stateless' -import { ChainId } from '@dao-dao/types' -import { - ActionChainContextType, - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { - decodePolytoneExecuteMsg, - getChainAddressForActionOptions, - getSupportedChainConfig, - makeWasmMessage, - maybeMakePolytoneExecuteMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { - InstantiateNftCollectionAction, - InstantiateNftCollectionData, -} from '../../../../components' -import { useActionOptions } from '../../../react' - -const Component: ActionComponent = (props) => { - const { context } = useActionOptions() - - return ( - <> - {context.type === ActionContextType.Dao && ( - - )} - - - - ) -} - -export const makeCreateNftCollectionAction: ActionMaker< - InstantiateNftCollectionData -> = (options) => { - const { - t, - chain: { chain_id: currentChainId }, - context, - chainContext, - } = options - - // Need to be on a supported chain to create an NFT collection. - if (chainContext.type !== ActionChainContextType.Supported) { - return null - } - - const useDefaults: UseDefaults = () => ({ - chainId: currentChainId, - name: '', - symbol: '', - }) - - const useTransformToCosmos: UseTransformToCosmos< - InstantiateNftCollectionData - > = () => - useCallback(({ chainId, name, symbol }: InstantiateNftCollectionData) => { - if ( - chainId === ChainId.StargazeMainnet || - chainId === ChainId.StargazeTestnet - ) { - throw new Error(t('error.cannotUseCreateNftCollectionOnStargaze')) - } - - const creator = getChainAddressForActionOptions(options, chainId) - if (!creator) { - throw new Error(t('error.loadingData')) - } - - return maybeMakePolytoneExecuteMessage( - currentChainId, - chainId, - makeWasmMessage({ - wasm: { - instantiate: { - admin: creator, - code_id: - getSupportedChainConfig(chainId)?.codeIds.Cw721Base ?? -1, - funds: [], - label: name, - msg: { - minter: creator, - name, - symbol, - }, - }, - }, - }) - ) - }, []) - - const useDecodedCosmosMsg: UseDecodedCosmosMsg< - InstantiateNftCollectionData - > = (msg: Record) => { - let chainId = currentChainId - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - } - - return objectMatchesStructure(msg, { - wasm: { - instantiate: { - code_id: {}, - label: {}, - msg: { - minter: {}, - name: {}, - symbol: {}, - }, - funds: {}, - }, - }, - }) - ? { - match: true, - data: { - chainId, - name: msg.wasm.instantiate.name, - symbol: msg.wasm.instantiate.symbol, - }, - } - : { - match: false, - } - } - - return { - key: ActionKey.CreateNftCollection, - Icon: ArtistPaletteEmoji, - label: t('title.createNftCollection'), - description: t('info.createNftCollectionDescription', { - context: context.type, - }), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - } -} diff --git a/packages/stateful/actions/core/nfts/ManageCw721/index.tsx b/packages/stateful/actions/core/nfts/ManageCw721/index.tsx deleted file mode 100644 index 342760c0b..000000000 --- a/packages/stateful/actions/core/nfts/ManageCw721/index.tsx +++ /dev/null @@ -1,354 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' -import { useFormContext } from 'react-hook-form' -import { useTranslation } from 'react-i18next' -import { - constSelector, - useRecoilValue, - useRecoilValueLoadable, - waitForNone, -} from 'recoil' - -import { CommonNftSelectors, DaoDaoCoreSelectors } from '@dao-dao/state/recoil' -import { ImageEmoji } from '@dao-dao/stateless' -import { Feature } from '@dao-dao/types' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { ContractInfoResponse } from '@dao-dao/types/contracts/Cw721Base' -import { - CW721_WORKAROUND_ITEM_KEY_PREFIX, - POLYTONE_CW721_ITEM_KEY_PREFIX, - getChainForChainId, - isValidBech32Address, - makeWasmMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { useActionOptions } from '../../../react' -import { - ManageCw721Data, - ManageCw721Component as StatelessManageCw721Component, -} from './Component' - -export const useDefaults: UseDefaults = () => { - const { - chain: { chain_id: chainId }, - } = useActionOptions() - - return { - chainId, - adding: true, - address: '', - workaround: false, - } -} - -const Component: ActionComponent = (props) => { - const { - address, - chain: { chain_id: currentChainId }, - } = useActionOptions() - - const { t } = useTranslation() - const { fieldNamePrefix } = props - - const { watch, setValue } = useFormContext() - - const chainId = watch((fieldNamePrefix + 'chainId') as 'chainId') - const { bech32_prefix: bech32Prefix } = getChainForChainId(chainId) - - const adding = watch(fieldNamePrefix + 'adding') - const tokenAddress = watch(fieldNamePrefix + 'address') - const workaround = watch(fieldNamePrefix + 'workaround') - - const tokenInfoLoadable = useRecoilValueLoadable( - tokenAddress && isValidBech32Address(tokenAddress, bech32Prefix) - ? CommonNftSelectors.contractInfoSelector({ - contractAddress: tokenAddress, - chainId, - params: [], - }) - : constSelector(undefined) - ) - - // If token info is improperly formatted, use workaround. - useEffect(() => { - if (tokenInfoLoadable.state !== 'hasValue' || !tokenInfoLoadable.contents) { - return - } - - // We expect keys to contain exactly `name` and `symbol`. If it contains - // anything else, use the workaround. - const keys = Object.keys(tokenInfoLoadable.contents) - if ( - keys.length !== 2 || - !keys.includes('name') || - !keys.includes('symbol') - ) { - if (!workaround) { - setValue(fieldNamePrefix + 'workaround', true) - } - } else { - if (workaround) { - setValue(fieldNamePrefix + 'workaround', false) - } - } - }, [fieldNamePrefix, setValue, tokenInfoLoadable, workaround]) - - const existingTokenAddresses = useRecoilValue( - DaoDaoCoreSelectors.allCw721CollectionsSelector({ - contractAddress: address, - chainId: currentChainId, - }) - )[chainId]?.collectionAddresses - const existingTokenInfos = useRecoilValue( - waitForNone( - existingTokenAddresses?.map((token) => - CommonNftSelectors.contractInfoSelector({ - contractAddress: token, - chainId, - params: [], - }) - ) ?? [] - ) - ) - const existingTokens = useMemo( - () => - (existingTokenAddresses || []).flatMap((address, idx) => - existingTokenInfos[idx].state === 'hasValue' && - existingTokenInfos[idx].contents - ? { - address, - info: existingTokenInfos[idx].contents as ContractInfoResponse, - } - : [] - ), - [existingTokenAddresses, existingTokenInfos] - ) - - const [additionalAddressError, setAdditionalAddressError] = useState() - useEffect(() => { - const tokenInfoErrored = tokenInfoLoadable.state === 'hasError' - const noTokensWhenRemoving = !adding && existingTokens.length === 0 - - if (!tokenInfoErrored && !noTokensWhenRemoving) { - if (additionalAddressError) { - setAdditionalAddressError(undefined) - } - return - } - - if (!additionalAddressError) { - setAdditionalAddressError( - tokenInfoErrored - ? t('error.notCw721Address') - : noTokensWhenRemoving - ? t('error.noNftCollections') - : // Should never happen. - t('error.unexpectedError') - ) - } - }, [ - tokenInfoLoadable.state, - t, - additionalAddressError, - existingTokens, - adding, - ]) - - return ( - - ) -} - -export const makeManageCw721Action: ActionMaker = ({ - t, - address, - context, - chain: { chain_id: chainId }, -}) => { - // Only DAOs. - if (context.type !== ActionContextType.Dao) { - return null - } - - const storageItemValueKey = context.dao.info.supportedFeatures[ - Feature.StorageItemValueKey - ] - ? 'value' - : 'addr' - - const useTransformToCosmos: UseTransformToCosmos = () => - useCallback( - (data: ManageCw721Data) => - makeWasmMessage({ - wasm: { - execute: { - contract_addr: address, - funds: [], - msg: - // If adding for polytone proxy (on other chain), use items - // instead of the core contract message, like the workaround - // below. - data.chainId !== chainId - ? data.adding - ? { - set_item: { - key: - POLYTONE_CW721_ITEM_KEY_PREFIX + - data.chainId + - ':' + - data.address, - [storageItemValueKey]: '1', - }, - } - : { - remove_item: { - key: - POLYTONE_CW721_ITEM_KEY_PREFIX + - data.chainId + - ':' + - data.address, - }, - } - : // If workaround, set item instead of using core contract message. This is necessary if the contract does not perfectly fit the expected NFT format. See the comment in `./Component.tsx` for more information. - data.workaround - ? data.adding - ? { - set_item: { - key: CW721_WORKAROUND_ITEM_KEY_PREFIX + data.address, - [storageItemValueKey]: '1', - }, - } - : { - remove_item: { - key: CW721_WORKAROUND_ITEM_KEY_PREFIX + data.address, - }, - } - : { - update_cw721_list: { - to_add: data.adding ? [data.address] : [], - to_remove: !data.adding ? [data.address] : [], - }, - }, - }, - }, - }), - [] - ) - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - if ( - objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - update_cw721_list: { - to_add: {}, - to_remove: {}, - }, - }, - }, - }, - }) && - msg.wasm.execute.contract_addr === address && - // Ensure only one collection is being added or removed, but not both, and - // not more than one collection. Ideally this component lets you add or - // remove multiple collections at once, but that's not supported yet. - ((msg.wasm.execute.msg.update_cw721_list.to_add.length === 1 && - msg.wasm.execute.msg.update_cw721_list.to_remove.length === 0) || - (msg.wasm.execute.msg.update_cw721_list.to_add.length === 0 && - msg.wasm.execute.msg.update_cw721_list.to_remove.length === 1)) - ) { - return { - match: true, - data: { - chainId, - adding: msg.wasm.execute.msg.update_cw721_list.to_add.length === 1, - address: - msg.wasm.execute.msg.update_cw721_list.to_add.length === 1 - ? msg.wasm.execute.msg.update_cw721_list.to_add[0] - : msg.wasm.execute.msg.update_cw721_list.to_remove[0], - workaround: false, - }, - } - } - - if ( - objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: {}, - }, - }, - }) && - msg.wasm.execute.contract_addr === address && - ('set_item' in msg.wasm.execute.msg || - 'remove_item' in msg.wasm.execute.msg) - ) { - const adding = 'set_item' in msg.wasm.execute.msg - const key = - (adding - ? msg.wasm.execute.msg.set_item.key - : msg.wasm.execute.msg.remove_item.key) ?? '' - - if (key.startsWith(CW721_WORKAROUND_ITEM_KEY_PREFIX)) { - return { - match: true, - data: { - chainId, - adding, - address: key.substring(CW721_WORKAROUND_ITEM_KEY_PREFIX.length), - workaround: true, - }, - } - } else if (key.startsWith(POLYTONE_CW721_ITEM_KEY_PREFIX)) { - // format is `prefix:[chainId]:[address]` - return { - match: true, - data: { - chainId: key.split(':')[1], - adding, - address: key.split(':')[2], - workaround: false, - }, - } - } - } - - return { match: false } - } - - return { - key: ActionKey.ManageCw721, - Icon: ImageEmoji, - label: t('title.manageTreasuryNfts'), - description: t('info.manageTreasuryNftsDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - } -} diff --git a/packages/stateful/actions/core/nfts/TransferNft/index.tsx b/packages/stateful/actions/core/nfts/TransferNft/index.tsx deleted file mode 100644 index a83f0001e..000000000 --- a/packages/stateful/actions/core/nfts/TransferNft/index.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import JSON5 from 'json5' -import { useCallback } from 'react' -import { useFormContext } from 'react-hook-form' -import { constSelector } from 'recoil' - -import { - lazyNftCardInfosForDaoSelector, - nftCardInfoSelector, - walletLazyNftCardInfosSelector, -} from '@dao-dao/state/recoil' -import { BoxEmoji, useCachedLoadingWithError } from '@dao-dao/stateless' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - LazyNftCardInfo, - LoadingDataWithError, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types' -import { - combineLoadingDataWithErrors, - decodeJsonFromBase64, - decodePolytoneExecuteMsg, - encodeJsonToBase64, - makeWasmMessage, - maybeMakePolytoneExecuteMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { AddressInput, NftSelectionModal } from '../../../../components' -import { useWallet } from '../../../../hooks' -import { useCw721CommonGovernanceTokenInfoIfExists } from '../../../../voting-module-adapter' -import { useActionOptions } from '../../../react' -import { TransferNftComponent, TransferNftData } from './Component' - -const useDefaults: UseDefaults = () => { - const { - chain: { chain_id: currentChainId }, - } = useActionOptions() - const { address: walletAddress = '' } = useWallet() - - return { - chainId: currentChainId, - collection: '', - tokenId: '', - recipient: walletAddress, - - executeSmartContract: false, - smartContractMsg: '{}', - } -} - -const useTransformToCosmos: UseTransformToCosmos = () => { - const { - chain: { chain_id: currentChainId }, - } = useActionOptions() - - return useCallback( - ({ - chainId, - collection, - tokenId, - recipient, - executeSmartContract, - smartContractMsg, - }: TransferNftData) => - maybeMakePolytoneExecuteMessage( - currentChainId, - chainId, - makeWasmMessage({ - wasm: { - execute: { - contract_addr: collection, - funds: [], - msg: executeSmartContract - ? { - send_nft: { - contract: recipient, - msg: encodeJsonToBase64(JSON5.parse(smartContractMsg)), - token_id: tokenId, - }, - } - : { - transfer_nft: { - recipient, - token_id: tokenId, - }, - }, - }, - }, - }) - ), - [currentChainId] - ) -} - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - let chainId = useActionOptions().chain.chain_id - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - } - - return objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - transfer_nft: { - recipient: {}, - token_id: {}, - }, - }, - }, - }, - }) - ? { - match: true, - data: { - chainId, - collection: msg.wasm.execute.contract_addr, - tokenId: msg.wasm.execute.msg.transfer_nft.token_id, - recipient: msg.wasm.execute.msg.transfer_nft.recipient, - - executeSmartContract: false, - smartContractMsg: '{}', - }, - } - : objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - send_nft: { - contract: {}, - msg: {}, - token_id: {}, - }, - }, - }, - }, - }) - ? { - match: true, - data: { - chainId, - collection: msg.wasm.execute.contract_addr, - tokenId: msg.wasm.execute.msg.send_nft.token_id, - recipient: msg.wasm.execute.msg.send_nft.contract, - - executeSmartContract: true, - smartContractMsg: JSON.stringify( - decodeJsonFromBase64(msg.wasm.execute.msg.send_nft.msg, true), - null, - 2 - ), - }, - } - : { match: false } -} - -const Component: ActionComponent = (props) => { - const { - context, - address, - chain: { chain_id: currentChainId }, - } = useActionOptions() - const { watch } = useFormContext() - const { denomOrAddress: governanceCollectionAddress } = - useCw721CommonGovernanceTokenInfoIfExists() ?? {} - - const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') - const tokenId = watch((props.fieldNamePrefix + 'tokenId') as 'tokenId') - const collection = watch( - (props.fieldNamePrefix + 'collection') as 'collection' - ) - - const options = useCachedLoadingWithError( - props.isCreating - ? context.type === ActionContextType.Wallet - ? walletLazyNftCardInfosSelector({ - walletAddress: address, - chainId: currentChainId, - }) - : lazyNftCardInfosForDaoSelector({ - chainId: currentChainId, - coreAddress: address, - governanceCollectionAddress, - }) - : undefined - ) - const nftInfo = useCachedLoadingWithError( - chainId && collection && tokenId - ? nftCardInfoSelector({ chainId, collection, tokenId }) - : constSelector(undefined) - ) - - const allChainOptions = - options.loading || options.errored - ? options - : combineLoadingDataWithErrors( - ...Object.values(options.data).filter( - (data): data is LoadingDataWithError => !!data - ) - ) - - return ( - - ) -} - -export const makeTransferNftAction: ActionMaker = ({ - t, - context: { type }, -}) => ({ - key: ActionKey.TransferNft, - Icon: BoxEmoji, - label: t('title.transferNft'), - description: t('info.transferNftDescription', { context: type }), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, -}) diff --git a/packages/stateful/actions/core/smart_contracting/Execute/index.tsx b/packages/stateful/actions/core/smart_contracting/Execute/index.tsx deleted file mode 100644 index cf64a63ad..000000000 --- a/packages/stateful/actions/core/smart_contracting/Execute/index.tsx +++ /dev/null @@ -1,445 +0,0 @@ -import { Coin } from '@cosmjs/stargate' -import JSON5 from 'json5' -import { useCallback, useEffect } from 'react' -import { useFormContext } from 'react-hook-form' - -import { genericTokenSelector } from '@dao-dao/state/recoil' -import { - ChainProvider, - DaoSupportedChainPickerInput, - SwordsEmoji, - useCachedLoadingWithError, -} from '@dao-dao/stateless' -import { AccountType, TokenType, UnifiedCosmosMsg } from '@dao-dao/types' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { MsgExecuteContract as SecretMsgExecuteContract } from '@dao-dao/types/protobuf/codegen/secret/compute/v1beta1/msg' -import { - bech32DataToAddress, - convertDenomToMicroDenomStringWithDecimals, - convertMicroDenomToDenomWithDecimals, - decodeIcaExecuteMsg, - decodeJsonFromBase64, - decodePolytoneExecuteMsg, - encodeJsonToBase64, - getAccountAddress, - isDecodedStargateMsg, - isSecretNetwork, - makeExecuteSmartContractMessage, - maybeMakeIcaExecuteMessage, - maybeMakePolytoneExecuteMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { useQueryTokens } from '../../../../hooks' -import { useTokenBalances } from '../../../hooks' -import { useActionOptions } from '../../../react' -import { - ExecuteData, - ExecuteComponent as StatelessExecuteComponent, -} from './Component' - -// Account types that are allowed to execute from. -const ALLOWED_ACCOUNT_TYPES: readonly AccountType[] = [ - AccountType.Native, - AccountType.Polytone, - AccountType.Ica, -] - -const useDefaults: UseDefaults = () => { - const { - chain: { chain_id: chainId }, - address, - } = useActionOptions() - - return { - chainId, - sender: address, - address: '', - message: '{}', - funds: [], - cw20: false, - } -} - -const useTransformToCosmos: UseTransformToCosmos = () => { - const { - address: currentAddress, - context, - chain: { chain_id: currentChainId }, - } = useActionOptions() - - return useCallback( - ({ chainId, sender, address, message, funds, cw20 }: ExecuteData) => { - const account = context.accounts.find( - (a) => a.chainId === chainId && a.address === sender - ) - if (!account) { - throw new Error('Instantiator account not found') - } - - const msg = JSON5.parse(message) - - let executeMsg: UnifiedCosmosMsg | undefined - if (cw20) { - if (funds.length !== 1) { - throw new Error('Missing CW20 fund denom.') - } - - // Execute CW20 send message. - const isSecret = isSecretNetwork(chainId) - executeMsg = makeExecuteSmartContractMessage({ - chainId, - sender, - contractAddress: funds[0].denom, - msg: { - send: { - amount: convertDenomToMicroDenomStringWithDecimals( - funds[0].amount, - funds[0].decimals - ), - [isSecret ? 'recipient' : 'contract']: address, - msg: encodeJsonToBase64(msg), - ...(isSecret && { - padding: '', - }), - }, - }, - }) - } else { - executeMsg = makeExecuteSmartContractMessage({ - chainId, - sender, - contractAddress: address, - msg, - funds: funds - .map(({ denom, amount, decimals }) => ({ - denom, - amount: convertDenomToMicroDenomStringWithDecimals( - amount, - decimals - ), - })) - // Neutron errors with `invalid coins` if the funds list is not - // alphabetized. - .sort((a, b) => a.denom.localeCompare(b.denom)), - }) - } - - return account.type === AccountType.Polytone - ? maybeMakePolytoneExecuteMessage( - currentChainId, - account.chainId, - executeMsg - ) - : account.type === AccountType.Ica - ? maybeMakeIcaExecuteMessage( - currentChainId, - account.chainId, - currentAddress, - account.address, - executeMsg - ) - : executeMsg - }, - [context.accounts, currentAddress, currentChainId] - ) -} - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - let { - chain: { chain_id: chainId }, - address: sender, - context: { accounts }, - } = useActionOptions() - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - sender = - getAccountAddress({ - accounts, - chainId, - types: [AccountType.Polytone], - }) || '' - } else { - const decodedIca = decodeIcaExecuteMsg(chainId, msg) - if (decodedIca.match) { - chainId = decodedIca.chainId - // should never be undefined since we check for 1 message in the decoder - msg = decodedIca.msgWithSender?.msg || {} - sender = decodedIca.msgWithSender?.sender || '' - } - } - - const isWasmExecute = objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: {}, - }, - }, - }) - - const isSecretExecuteMsg = - isDecodedStargateMsg(msg) && - msg.stargate.typeUrl === SecretMsgExecuteContract.typeUrl - - const executeMsg = isWasmExecute - ? msg.wasm.execute.msg - : isSecretExecuteMsg - ? decodeJsonFromBase64(msg.stargate.value.msg) - : undefined - - // Check if a CW20 execute, which is a subset of execute. - const isCw20 = - (isWasmExecute && - objectMatchesStructure(executeMsg, { - send: { - amount: {}, - contract: {}, - msg: {}, - }, - })) || - (isSecretExecuteMsg && - objectMatchesStructure(executeMsg, { - send: { - amount: {}, - recipient: {}, - msg: {}, - padding: {}, - }, - })) - - const cw20Token = useCachedLoadingWithError( - isCw20 - ? genericTokenSelector({ - chainId, - type: TokenType.Cw20, - denomOrAddress: isWasmExecute - ? msg.wasm.execute.contract_addr - : bech32DataToAddress(chainId, msg.stargate.value.contract), - }) - : undefined - ) - - const funds: Coin[] | undefined = isWasmExecute - ? msg.wasm.execute.funds - : isSecretExecuteMsg - ? msg.stargate.value.sentFunds - : undefined - - const fundsTokens = useQueryTokens( - funds?.length && !isCw20 - ? funds.map(({ denom }) => ({ - chainId, - type: TokenType.Native, - denomOrAddress: denom, - })) - : undefined - ) - - // Can't match until we have the token info. - if ( - (!isWasmExecute && !isSecretExecuteMsg) || - (isCw20 && (cw20Token.loading || cw20Token.errored)) || - (!isCw20 && !!funds?.length && (fundsTokens.loading || fundsTokens.errored)) - ) { - return { match: false } - } - - const cw20Decimals = - !cw20Token.loading && !cw20Token.errored ? cw20Token.data.decimals : 0 - - return isWasmExecute - ? { - match: true, - data: { - chainId, - sender, - address: isCw20 - ? executeMsg.send.contract - : msg.wasm.execute.contract_addr, - message: JSON.stringify( - isCw20 - ? decodeJsonFromBase64(executeMsg.send.msg, true) - : executeMsg, - null, - 2 - ), - funds: isCw20 - ? [ - { - denom: msg.wasm.execute.contract_addr, - amount: convertMicroDenomToDenomWithDecimals( - executeMsg.send.amount, - cw20Decimals - ), - decimals: cw20Decimals, - }, - ] - : !fundsTokens.loading && !fundsTokens.errored && funds - ? funds.map(({ denom, amount }, index) => ({ - denom, - amount: convertMicroDenomToDenomWithDecimals( - amount, - fundsTokens.data[index].decimals - ), - decimals: fundsTokens.data[index].decimals, - })) - : [], - cw20: isCw20, - }, - } - : isSecretExecuteMsg - ? { - match: true, - data: { - chainId, - sender, - address: isCw20 - ? executeMsg.send.recipient - : bech32DataToAddress(chainId, msg.stargate.value.contract), - message: JSON.stringify( - isCw20 - ? decodeJsonFromBase64(executeMsg.send.msg, true) - : executeMsg, - null, - 2 - ), - funds: isCw20 - ? [ - { - denom: bech32DataToAddress( - chainId, - msg.stargate.value.contract - ), - amount: convertMicroDenomToDenomWithDecimals( - executeMsg.send.amount, - cw20Decimals - ), - decimals: cw20Decimals, - }, - ] - : !fundsTokens.loading && !fundsTokens.errored && funds - ? funds.map(({ denom, amount }, index) => ({ - denom, - amount: convertMicroDenomToDenomWithDecimals( - amount, - fundsTokens.data[index].decimals - ), - decimals: fundsTokens.data[index].decimals, - })) - : [], - cw20: isCw20, - }, - } - : { - match: false, - } -} - -const Component: ActionComponent = (props) => { - const { context } = useActionOptions() - const { watch, setValue } = useFormContext() - - const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') - const sender = watch((props.fieldNamePrefix + 'sender') as 'sender') - const funds = watch((props.fieldNamePrefix + 'funds') as 'funds') - const cw20 = watch((props.fieldNamePrefix + 'cw20') as 'cw20') - - const tokens = useTokenBalances({ - // Load selected tokens when not creating in case they are no longer - // returned in the list of all tokens for the given DAO/wallet after the - // proposal is made. - additionalTokens: props.isCreating - ? undefined - : funds.map(({ denom }) => ({ - chainId, - type: cw20 ? TokenType.Cw20 : TokenType.Native, - denomOrAddress: denom, - })), - }) - - // If sender is not found in the list of accounts, reset to the first account - // on the target chain, or an empty account. - useEffect(() => { - if ( - sender && - !context.accounts.some( - (a) => a.chainId === chainId && a.address === sender - ) - ) { - setValue( - (props.fieldNamePrefix + 'sender') as 'sender', - getAccountAddress({ - accounts: context.accounts, - chainId, - types: ALLOWED_ACCOUNT_TYPES, - }) || '' - ) - } - }, [chainId, context.accounts, props.fieldNamePrefix, sender, setValue]) - - return ( - <> - {context.type === ActionContextType.Dao && ( - { - // Reset funds when switching chain. - setValue((props.fieldNamePrefix + 'funds') as 'funds', []) - // Default sender to first matching account on new chain. - setValue( - (props.fieldNamePrefix + 'sender') as 'sender', - getAccountAddress({ - accounts: context.accounts, - chainId, - types: ALLOWED_ACCOUNT_TYPES, - }) || '' - ) - }} - /> - )} - - - - token.chainId === chainId && owner.address === sender - ), - }, - }} - /> - - - ) -} - -export const makeExecuteAction: ActionMaker = ({ t }) => ({ - key: ActionKey.Execute, - Icon: SwordsEmoji, - label: t('title.executeSmartContract'), - description: t('info.executeSmartContractActionDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, -}) diff --git a/packages/stateful/actions/core/smart_contracting/FeeShare/index.tsx b/packages/stateful/actions/core/smart_contracting/FeeShare/index.tsx deleted file mode 100644 index 799d583d7..000000000 --- a/packages/stateful/actions/core/smart_contracting/FeeShare/index.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { useCallback, useMemo } from 'react' - -import { GasEmoji } from '@dao-dao/stateless' -import { ChainId, makeStargateMessage } from '@dao-dao/types' -import { - ActionComponent, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { - MsgRegisterFeeShare, - MsgUpdateFeeShare, -} from '@dao-dao/types/protobuf/codegen/juno/feeshare/v1/tx' -import { isDecodedStargateMsg } from '@dao-dao/utils' - -import { AddressInput } from '../../../../components/AddressInput' -import { FeeShareComponent, FeeShareData } from './Component' - -const useDefaults: UseDefaults = () => ({ - typeUrl: MsgRegisterFeeShare.typeUrl, - contract: '', - showWithdrawer: false, - withdrawer: '', -}) - -const Component: ActionComponent = (props) => ( - -) - -export const makeFeeShareAction: ActionMaker = ({ - t, - address, - chain, -}) => { - // Only supported on Juno. - if ( - chain.chain_id !== ChainId.JunoMainnet && - chain.chain_id !== ChainId.JunoTestnet - ) { - return null - } - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => - useMemo(() => { - if ( - !isDecodedStargateMsg(msg) || - (msg.stargate.typeUrl !== MsgRegisterFeeShare.typeUrl && - msg.stargate.typeUrl !== MsgUpdateFeeShare.typeUrl) - ) { - return { - match: false, - } - } - - const { contractAddress, withdrawerAddress } = msg.stargate.value - - return { - match: true, - data: { - typeUrl: msg.stargate.typeUrl, - contract: contractAddress, - showWithdrawer: withdrawerAddress !== address, - withdrawer: withdrawerAddress, - }, - } - }, [msg]) - - const useTransformToCosmos: UseTransformToCosmos = () => - useCallback((data: FeeShareData) => { - const { contract, showWithdrawer, typeUrl, withdrawer } = data - - return makeStargateMessage({ - stargate: { - typeUrl, - value: { - contractAddress: contract, - deployerAddress: address, - withdrawerAddress: (showWithdrawer && withdrawer) || address, - }, - }, - }) - }, []) - - return { - key: ActionKey.FeeShare, - Icon: GasEmoji, - label: t('title.feeShare'), - description: t('info.feeShareDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - } -} diff --git a/packages/stateful/actions/core/smart_contracting/Instantiate2/index.tsx b/packages/stateful/actions/core/smart_contracting/Instantiate2/index.tsx deleted file mode 100644 index c437ec327..000000000 --- a/packages/stateful/actions/core/smart_contracting/Instantiate2/index.tsx +++ /dev/null @@ -1,398 +0,0 @@ -import { instantiate2Address } from '@cosmjs/cosmwasm-stargate' -import { fromHex, fromUtf8, toBase64, toUtf8 } from '@cosmjs/encoding' -import { Coin } from '@cosmjs/stargate' -import JSON5 from 'json5' -import { useCallback, useEffect } from 'react' -import { useFormContext } from 'react-hook-form' -import { constSelector, useRecoilValueLoadable } from 'recoil' -import { v4 as uuidv4 } from 'uuid' - -import { codeDetailsSelector } from '@dao-dao/state/recoil' -import { - BabyAngelEmoji, - ChainProvider, - DaoSupportedChainPickerInput, -} from '@dao-dao/stateless' -import { AccountType, TokenType, makeStargateMessage } from '@dao-dao/types' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { MsgInstantiateContract2 } from '@dao-dao/types/protobuf/codegen/cosmwasm/wasm/v1/tx' -import { - convertDenomToMicroDenomStringWithDecimals, - convertMicroDenomToDenomWithDecimals, - decodeIcaExecuteMsg, - decodeJsonFromBase64, - decodePolytoneExecuteMsg, - getAccountAddress, - getChainAddressForActionOptions, - isDecodedStargateMsg, - isSecretNetwork, - maybeGetChainForChainId, - maybeMakeIcaExecuteMessage, - maybeMakePolytoneExecuteMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { useQueryTokens } from '../../../../hooks' -import { useTokenBalances } from '../../../hooks' -import { useActionOptions } from '../../../react' -import { - Instantiate2Data, - Instantiate2Component as StatelessInstantiate2Component, -} from './Component' - -// Account types that are allowed to instantiate from. -const ALLOWED_ACCOUNT_TYPES: readonly AccountType[] = [ - AccountType.Native, - AccountType.Polytone, - AccountType.Ica, -] - -const Component: ActionComponent = (props) => { - const { context, address } = useActionOptions() - - const { watch, setValue } = useFormContext() - const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') - const codeId = watch((props.fieldNamePrefix + 'codeId') as 'codeId') - const salt = watch((props.fieldNamePrefix + 'salt') as 'salt') - const funds = watch((props.fieldNamePrefix + 'funds') as 'funds') - - const sender = watch((props.fieldNamePrefix + 'sender') as 'sender') - // If sender is not found in the list of accounts, reset to the first account - // on the target chain, or an empty account. - useEffect(() => { - if ( - sender && - !context.accounts.some( - (a) => a.chainId === chainId && a.address === sender - ) - ) { - setValue( - (props.fieldNamePrefix + 'sender') as 'sender', - getAccountAddress({ - accounts: context.accounts, - chainId, - types: ALLOWED_ACCOUNT_TYPES, - }) || '' - ) - } - }, [chainId, context.accounts, props.fieldNamePrefix, sender, setValue]) - - // Load checksum of the contract code. - const codeDetailsLoadable = useRecoilValueLoadable( - chainId && codeId && !isNaN(codeId) - ? codeDetailsSelector({ - chainId, - codeId, - }) - : constSelector(undefined) - ) - - const nativeBalances = useTokenBalances({ - filter: TokenType.Native, - // Load selected tokens when not creating in case they are no longer - // returned in the list of all tokens for the given DAO/wallet after the - // proposal is made. - additionalTokens: props.isCreating - ? undefined - : funds.map(({ denom }) => ({ - chainId, - type: TokenType.Native, - denomOrAddress: denom, - })), - }) - - const chain = maybeGetChainForChainId(chainId) - - const instantiatedAddress = - codeDetailsLoadable.state === 'hasValue' && - codeDetailsLoadable.contents && - chain - ? instantiate2Address( - fromHex(codeDetailsLoadable.contents.checksum), - address, - toUtf8(salt), - chain.bech32_prefix - ) - : undefined - - return ( - <> - {context.type === ActionContextType.Dao && ( - { - // Reset funds and update admin/sender when switching chain. - setValue((props.fieldNamePrefix + 'funds') as 'funds', []) - - const chainAddress = - getAccountAddress({ - accounts: context.accounts, - chainId, - types: ALLOWED_ACCOUNT_TYPES, - }) || '' - setValue((props.fieldNamePrefix + 'admin') as 'admin', chainAddress) - setValue( - (props.fieldNamePrefix + 'sender') as 'sender', - chainAddress - ) - }} - /> - )} - - - - token.chainId === chainId && owner.address === sender - ), - }, - instantiatedAddress, - }} - /> - - - ) -} - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - let { - chain: { chain_id: chainId }, - address: sender, - context: { accounts }, - } = useActionOptions() - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - sender = - getAccountAddress({ - accounts, - chainId, - types: [AccountType.Polytone], - }) || '' - } else { - const decodedIca = decodeIcaExecuteMsg(chainId, msg) - if (decodedIca.match) { - chainId = decodedIca.chainId - // should never be undefined since we check for 1 message in the decoder - msg = decodedIca.msgWithSender?.msg || {} - sender = decodedIca.msgWithSender?.sender || '' - } - } - - // Convert to CW msg format to use same matching logic below. - if ( - isDecodedStargateMsg(msg) && - msg.stargate.typeUrl === MsgInstantiateContract2.typeUrl - ) { - msg = { - wasm: { - instantiate2: { - admin: msg.stargate.value.admin, - code_id: Number(msg.stargate.value.codeId), - label: msg.stargate.value.label, - msg: decodeJsonFromBase64(toBase64(msg.stargate.value.msg), true), - funds: msg.stargate.value.funds, - fix_msg: msg.stargate.value.fixMsg, - salt: fromUtf8(msg.stargate.value.salt), - }, - }, - } - } - - const isWasmInstantiate2Msg = objectMatchesStructure(msg, { - wasm: { - instantiate2: { - code_id: {}, - label: {}, - msg: {}, - funds: {}, - salt: {}, - fix_msg: {}, - }, - }, - }) - - const funds: Coin[] | undefined = isWasmInstantiate2Msg - ? msg.wasm.instantiate2.funds - : undefined - const fundsTokens = useQueryTokens( - funds?.map(({ denom }) => ({ - chainId, - type: TokenType.Native, - denomOrAddress: denom, - })) - ) - - // Can't match until we have the token info. - if (fundsTokens.loading || fundsTokens.errored) { - return { match: false } - } - - return isWasmInstantiate2Msg - ? { - match: true, - data: { - chainId, - sender, - admin: msg.wasm.instantiate2.admin ?? '', - codeId: msg.wasm.instantiate2.code_id, - label: msg.wasm.instantiate2.label, - message: JSON.stringify(msg.wasm.instantiate2.msg, undefined, 2), - salt: msg.wasm.instantiate2.salt, - funds: (msg.wasm.instantiate2.funds as Coin[]).map( - ({ denom, amount }, index) => ({ - denom, - amount: Number( - convertMicroDenomToDenomWithDecimals( - amount, - fundsTokens.data[index].decimals - ) - ), - decimals: fundsTokens.data[index].decimals, - }) - ), - _polytone: decodedPolytone.match - ? { - chainId: decodedPolytone.chainId, - note: decodedPolytone.polytoneConnection, - initiatorMsg: decodedPolytone.initiatorMsg, - } - : undefined, - }, - } - : { - match: false, - } -} - -export const makeInstantiate2Action: ActionMaker = ( - options -) => { - const { - t, - address, - chain: { chain_id: currentChainId }, - context, - } = options - - if ( - // Secret Network does not support instantiate2. - isSecretNetwork(currentChainId) - ) { - return null - } - - const useDefaults: UseDefaults = () => ({ - chainId: currentChainId, - sender: address, - admin: address, - codeId: 0, - label: '', - message: '{}', - salt: uuidv4(), - funds: [], - }) - - const useTransformToCosmos: UseTransformToCosmos = () => - useCallback( - ({ - chainId, - sender, - admin, - codeId, - label, - message, - salt, - funds, - }: Instantiate2Data) => { - const account = context.accounts.find( - (a) => a.chainId === chainId && a.address === sender - ) - if (!account) { - throw new Error('Instantiator account not found') - } - - let msg - try { - msg = JSON5.parse(message) - } catch (err) { - console.error(`internal error. unparsable message: (${message})`, err) - return - } - - const convertedFunds = funds - .map(({ denom, amount, decimals }) => ({ - denom, - amount: convertDenomToMicroDenomStringWithDecimals( - amount, - decimals - ), - })) - // Neutron errors with `invalid coins` if the funds list is not - // alphabetized. - .sort((a, b) => a.denom.localeCompare(b.denom)) - - const instantiateMsg = makeStargateMessage({ - stargate: { - typeUrl: MsgInstantiateContract2.typeUrl, - value: MsgInstantiateContract2.fromPartial({ - sender: getChainAddressForActionOptions(options, chainId), - admin: admin || '', - codeId: codeId ? BigInt(codeId) : 0n, - label, - msg: toUtf8(JSON.stringify(msg)), - funds: convertedFunds, - salt: toUtf8(salt), - fixMsg: false, - }), - }, - }) - - return account.type === AccountType.Polytone - ? maybeMakePolytoneExecuteMessage( - currentChainId, - account.chainId, - instantiateMsg - ) - : account.type === AccountType.Ica - ? maybeMakeIcaExecuteMessage( - currentChainId, - account.chainId, - address, - account.address, - instantiateMsg - ) - : instantiateMsg - }, - [] - ) - - return { - key: ActionKey.Instantiate2, - Icon: BabyAngelEmoji, - label: t('title.instantiatePredictableSmartContract'), - description: t('info.instantiatePredictableSmartContractActionDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - } -} diff --git a/packages/stateful/actions/core/smart_contracting/Migrate/index.tsx b/packages/stateful/actions/core/smart_contracting/Migrate/index.tsx deleted file mode 100644 index 8823b3501..000000000 --- a/packages/stateful/actions/core/smart_contracting/Migrate/index.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import JSON5 from 'json5' -import { useCallback, useState } from 'react' -import { useFormContext } from 'react-hook-form' -import { useRecoilValueLoadable } from 'recoil' - -import { contractAdminSelector } from '@dao-dao/state' -import { - ChainProvider, - DaoSupportedChainPickerInput, - WhaleEmoji, -} from '@dao-dao/stateless' -import { makeStargateMessage } from '@dao-dao/types' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { MsgMigrateContract as SecretMsgMigrateContract } from '@dao-dao/types/protobuf/codegen/secret/compute/v1beta1/msg' -import { - bech32AddressToBase64, - bech32DataToAddress, - decodeJsonFromBase64, - decodePolytoneExecuteMsg, - encodeJsonToBase64, - getChainAddressForActionOptions, - isDecodedStargateMsg, - isSecretNetwork, - makeWasmMessage, - maybeMakePolytoneExecuteMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { useActionOptions } from '../../../react' -import { MigrateContractComponent as StatelessMigrateContractComponent } from './Component' - -interface MigrateData { - chainId: string - contract: string - codeId: number - msg: string -} - -const useDefaults: UseDefaults = () => { - const { - chain: { chain_id: chainId }, - } = useActionOptions() - - return { - chainId, - contract: '', - codeId: 0, - msg: '{}', - } -} - -const useTransformToCosmos: UseTransformToCosmos = () => { - const options = useActionOptions() - - return useCallback( - ({ chainId, contract, codeId, msg: msgString }: MigrateData) => { - let msg - try { - msg = JSON5.parse(msgString) - } catch (err) { - console.error(`internal error. unparsable message: (${msg})`, err) - return - } - - return maybeMakePolytoneExecuteMessage( - options.chain.chain_id, - chainId, - isSecretNetwork(chainId) - ? makeStargateMessage({ - stargate: { - typeUrl: SecretMsgMigrateContract.typeUrl, - value: SecretMsgMigrateContract.fromAmino({ - sender: bech32AddressToBase64( - getChainAddressForActionOptions(options, chainId) || '' - ), - contract: bech32AddressToBase64(contract), - code_id: BigInt(codeId).toString(), - msg: encodeJsonToBase64(msg), - }), - }, - }) - : makeWasmMessage({ - wasm: { - migrate: { - contract_addr: contract, - new_code_id: codeId, - msg, - }, - }, - }) - ) - }, - [options] - ) -} - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - let chainId = useActionOptions().chain.chain_id - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - } - - return objectMatchesStructure(msg, { - wasm: { - migrate: { - contract_addr: {}, - new_code_id: {}, - msg: {}, - }, - }, - }) - ? { - match: true, - data: { - chainId, - contract: msg.wasm.migrate.contract_addr, - codeId: msg.wasm.migrate.new_code_id, - msg: JSON.stringify(msg.wasm.migrate.msg, undefined, 2), - }, - } - : isDecodedStargateMsg(msg) && - msg.stargate.typeUrl === SecretMsgMigrateContract.typeUrl - ? { - match: true, - data: { - chainId, - contract: bech32DataToAddress(chainId, msg.stargate.value.contract), - codeId: Number(msg.stargate.value.codeId), - msg: JSON.stringify(decodeJsonFromBase64(msg.stargate.value.msg)), - }, - } - : { - match: false, - } -} - -const Component: ActionComponent = (props) => { - const { context } = useActionOptions() - const { watch } = useFormContext() - const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') - - const [contract, setContract] = useState('') - - const admin = useRecoilValueLoadable( - contractAdminSelector({ - chainId, - contractAddress: contract, - }) - ) - - return ( - <> - {context.type === ActionContextType.Dao && ( - - )} - - - setContract(contract), - }} - /> - - - ) -} - -export const makeMigrateAction: ActionMaker = ({ t }) => ({ - key: ActionKey.Migrate, - Icon: WhaleEmoji, - label: t('title.migrateSmartContract'), - description: t('info.migrateSmartContractActionDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, -}) diff --git a/packages/stateful/actions/core/smart_contracting/UpdateAdmin/index.tsx b/packages/stateful/actions/core/smart_contracting/UpdateAdmin/index.tsx deleted file mode 100644 index dd84bb62d..000000000 --- a/packages/stateful/actions/core/smart_contracting/UpdateAdmin/index.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { useCallback } from 'react' -import { useFormContext } from 'react-hook-form' -import { constSelector, useRecoilValueLoadable } from 'recoil' - -import { contractAdminSelector } from '@dao-dao/state' -import { - ChainProvider, - DaoSupportedChainPickerInput, - MushroomEmoji, -} from '@dao-dao/stateless' -import { makeStargateMessage } from '@dao-dao/types' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { MsgUpdateAdmin as SecretMsgUpdateAdmin } from '@dao-dao/types/protobuf/codegen/secret/compute/v1beta1/msg' -import { - decodePolytoneExecuteMsg, - getChainAddressForActionOptions, - getChainForChainId, - isDecodedStargateMsg, - isSecretNetwork, - isValidBech32Address, - maybeMakePolytoneExecuteMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { useActionOptions } from '../../../react' -import { UpdateAdminComponent as StatelessUpdateAdminComponent } from './Component' - -export type UpdateAdminData = { - chainId: string - contract: string - newAdmin: string -} - -const useDefaults: UseDefaults = () => { - const { - chain: { chain_id: chainId }, - } = useActionOptions() - - return { - chainId, - contract: '', - newAdmin: '', - } -} - -const useTransformToCosmos: UseTransformToCosmos = () => { - const options = useActionOptions() - - return useCallback( - ({ chainId, contract, newAdmin }: UpdateAdminData) => - maybeMakePolytoneExecuteMessage( - options.chain.chain_id, - chainId, - isSecretNetwork(chainId) - ? makeStargateMessage({ - stargate: { - typeUrl: SecretMsgUpdateAdmin.typeUrl, - value: SecretMsgUpdateAdmin.fromAmino({ - sender: - getChainAddressForActionOptions(options, chainId) || '', - contract, - new_admin: newAdmin, - }), - }, - }) - : { - wasm: { - update_admin: { - contract_addr: contract, - admin: newAdmin, - }, - }, - } - ), - [options] - ) -} - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - let chainId = useActionOptions().chain.chain_id - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - } - - return objectMatchesStructure(msg, { - wasm: { - update_admin: { - contract_addr: {}, - admin: {}, - }, - }, - }) - ? { - match: true, - data: { - chainId, - contract: msg.wasm.update_admin.contract_addr, - newAdmin: msg.wasm.update_admin.admin, - }, - } - : isDecodedStargateMsg(msg) && - msg.stargate.typeUrl === SecretMsgUpdateAdmin.typeUrl - ? { - match: true, - data: { - chainId, - contract: msg.stargate.value.contract, - newAdmin: msg.stargate.value.newAdmin, - }, - } - : { - match: false, - } -} - -const Component: ActionComponent = (props) => { - const { context } = useActionOptions() - const { watch } = useFormContext() - - const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') - const { bech32_prefix: bech32Prefix } = getChainForChainId(chainId) - - const contract = watch((props.fieldNamePrefix + 'contract') as 'contract') - - const admin = useRecoilValueLoadable( - contract && isValidBech32Address(contract, bech32Prefix) - ? contractAdminSelector({ - contractAddress: contract, - chainId, - }) - : constSelector(undefined) - ) - - return ( - <> - {context.type === ActionContextType.Dao && ( - - )} - - - - - - ) -} - -export const makeUpdateAdminAction: ActionMaker = ({ t }) => ({ - key: ActionKey.UpdateAdmin, - Icon: MushroomEmoji, - label: t('title.updateContractAdmin'), - description: t('info.updateContractAdminActionDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, -}) diff --git a/packages/stateful/actions/core/smart_contracting/UploadCode/index.tsx b/packages/stateful/actions/core/smart_contracting/UploadCode/index.tsx deleted file mode 100644 index 755456f89..000000000 --- a/packages/stateful/actions/core/smart_contracting/UploadCode/index.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { fromBase64, fromBech32, toBase64 } from '@cosmjs/encoding' -import { useCallback } from 'react' -import { Trans } from 'react-i18next' - -import { - ComputerDiskEmoji, - DaoSupportedChainPickerInput, -} from '@dao-dao/stateless' -import { makeStargateMessage } from '@dao-dao/types' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { MsgStoreCode } from '@dao-dao/types/protobuf/codegen/cosmwasm/wasm/v1/tx' -import { AccessType } from '@dao-dao/types/protobuf/codegen/cosmwasm/wasm/v1/types' -import { MsgStoreCode as SecretMsgStoreCode } from '@dao-dao/types/protobuf/codegen/secret/compute/v1beta1/msg' -import { - decodePolytoneExecuteMsg, - getChainAddressForActionOptions, - isDecodedStargateMsg, - isGzipped, - isSecretNetwork, - maybeMakePolytoneExecuteMessage, -} from '@dao-dao/utils' - -import { AddressInput } from '../../../../components' -import { useActionOptions } from '../../../react' -import { UploadCodeComponent, UploadCodeData } from './Component' - -const Component: ActionComponent = (props) => { - const { context } = useActionOptions() - - return ( - <> - {context.type === ActionContextType.Dao && ( - - )} - - - - ) -} - -export const makeUploadCodeAction: ActionMaker = (options) => { - const { - t, - address, - chain: { chain_id: currentChainId }, - context, - } = options - - const useDefaults: UseDefaults = () => ({ - chainId: currentChainId, - accessType: AccessType.Everybody, - allowedAddresses: [{ address }], - }) - - const useTransformToCosmos: UseTransformToCosmos = () => - useCallback( - ({ chainId, data, accessType, allowedAddresses }: UploadCodeData) => { - if (!data) { - return - } - - const isSecret = isSecretNetwork(chainId) - const sender = getChainAddressForActionOptions(options, chainId) || '' - - return maybeMakePolytoneExecuteMessage( - currentChainId, - chainId, - makeStargateMessage({ - stargate: { - typeUrl: isSecret - ? SecretMsgStoreCode.typeUrl - : MsgStoreCode.typeUrl, - value: { - sender: isSecret ? fromBech32(sender).data : address, - wasmByteCode: fromBase64(data), - instantiatePermission: { - permission: accessType, - addresses: - accessType === AccessType.AnyOfAddresses - ? allowedAddresses.map(({ address }) => address) - : [], - }, - }, - }, - }) - ) - }, - [] - ) - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - let chainId = currentChainId - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - } - - if ( - !isDecodedStargateMsg(msg) || - (msg.stargate.typeUrl !== MsgStoreCode.typeUrl && - msg.stargate.typeUrl !== SecretMsgStoreCode.typeUrl) - ) { - return { - match: false, - } - } - - const wasmByteCode = msg.stargate.value.wasmByteCode as Uint8Array - // gzipped data starts with 0x1f 0x8b - const gzipped = - wasmByteCode instanceof Uint8Array && isGzipped(wasmByteCode) - - return { - match: true, - data: { - chainId, - data: toBase64(wasmByteCode), - gzipped, - accessType: - msg.stargate.value.instantiatePermission?.permission ?? - AccessType.UNRECOGNIZED, - allowedAddresses: - msg.stargate.value.instantiatePermission?.addresses?.map( - (address: string) => ({ address }) - ) ?? [], - }, - } - } - - return { - key: ActionKey.UploadCode, - Icon: ComputerDiskEmoji, - label: t('title.uploadSmartContractCode'), - description: t('info.uploadSmartContractCodeActionDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - // DAO proposal modules limit the sizes of proposals such that uploading - // almost any wasm file is impossible. Until we figure out a workaround, - // hide it from being selected in a DAO. Wallets and chain governance - // proposals can still use this. - hideFromPicker: context.type === ActionContextType.Dao, - } -} diff --git a/packages/stateful/actions/core/smart_contracting/index.ts b/packages/stateful/actions/core/smart_contracting/index.ts deleted file mode 100644 index 0aec32ea7..000000000 --- a/packages/stateful/actions/core/smart_contracting/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ActionCategoryKey, ActionCategoryMaker } from '@dao-dao/types' - -import { makeExecuteAction } from './Execute' -import { makeFeeShareAction } from './FeeShare' -import { makeInstantiateAction } from './Instantiate' -import { makeInstantiate2Action } from './Instantiate2' -import { makeMigrateAction } from './Migrate' -import { makeUpdateAdminAction } from './UpdateAdmin' -import { makeUploadCodeAction } from './UploadCode' - -export const makeSmartContractingActionCategory: ActionCategoryMaker = ({ - t, -}) => ({ - key: ActionCategoryKey.SmartContracting, - label: t('actionCategory.smartContractingLabel'), - description: t('actionCategory.smartContractingDescription'), - actionMakers: [ - makeInstantiateAction, - makeInstantiate2Action, - makeExecuteAction, - makeMigrateAction, - makeUpdateAdminAction, - makeUploadCodeAction, - makeFeeShareAction, - ], -}) diff --git a/packages/stateful/actions/core/subdaos/AcceptSubDao/Component.tsx b/packages/stateful/actions/core/subdaos/AcceptSubDao/Component.tsx deleted file mode 100644 index 6f8ada92c..000000000 --- a/packages/stateful/actions/core/subdaos/AcceptSubDao/Component.tsx +++ /dev/null @@ -1,286 +0,0 @@ -import { ComponentType, useEffect, useState } from 'react' -import { useFormContext } from 'react-hook-form' -import { useTranslation } from 'react-i18next' -import { v4 as uuidv4 } from 'uuid' - -import { InputErrorMessage, InputLabel, useChain } from '@dao-dao/stateless' -import { AddressInputProps } from '@dao-dao/types' -import { - ActionComponent, - ActionKey, - ActionKeyAndData, -} from '@dao-dao/types/actions' -import { - getChainAddressForActionOptions, - isValidBech32Address, - makeValidateAddress, - validateRequired, -} from '@dao-dao/utils' - -import { useActionOptions } from '../../../react' -import { DaoAdminExecData } from '../../dao_governance/DaoAdminExec/Component' -import { UpdateAdminData } from '../../smart_contracting/UpdateAdmin' -import { ManageSubDaosData } from '../ManageSubDaos/Component' - -export type AcceptSubDaoData = { - chainId: string - address: string -} - -type AcceptSubDaoDataOptions = { - AddressInput: ComponentType> -} - -export const AcceptSubDaoComponent: ActionComponent< - AcceptSubDaoDataOptions, - AcceptSubDaoData -> = ({ - fieldNamePrefix, - errors, - isCreating, - allActionsWithData, - index, - addAction, - options: { AddressInput }, -}) => { - const { t } = useTranslation() - const options = useActionOptions() - - const { chain_id: chainId, bech32_prefix: bech32Prefix } = useChain() - const currentAddress = getChainAddressForActionOptions(options, chainId) - - const { watch, register, setValue, getValues } = - useFormContext() - - const addressFieldName = (fieldNamePrefix + 'address') as 'address' - - const address = watch(addressFieldName) - const isValid = !!address && isValidBech32Address(address, bech32Prefix) - const [otherActionsAdded, setOtherActionsAdded] = useState(false) - useEffect(() => { - if (!isCreating || !isValid) { - return - } - - // Check if this is being used within a cross-chain execute action. If so, - // we need to add the manage subDAOs action to the parent action context, - // which is most likely a DAO. - const parentActionRootFieldNamePrefix = fieldNamePrefix.replace( - new RegExp(`data\\._actionData\\.${index}\\.data\\.$`), - '' - ) - const outerCrossChainExecuteIndex = - parentActionRootFieldNamePrefix.length < fieldNamePrefix.length && - getValues((parentActionRootFieldNamePrefix + 'actionKey') as any) === - ActionKey.CrossChainExecute - ? // trim dot at the end, and then the index is the last item. it probably looks like: `actionData.INDEX.` - Number(parentActionRootFieldNamePrefix.slice(0, -1).split('.').pop()) - : undefined - // remove outer cross-chain execute index from field name prefix to get list - // of all actions in the parent context - const parentActionDataListFieldName = - parentActionRootFieldNamePrefix.replace( - new RegExp(`.${outerCrossChainExecuteIndex}.$`), - '' - ) - - const existingUpdateAdminIndex = allActionsWithData.findIndex( - (a, i) => - i > index && - a.actionKey === ActionKey.DaoAdminExec && - (a.data as DaoAdminExecData)?._actionData?.length === 1 && - (a.data as DaoAdminExecData)._actionData![0].actionKey === - ActionKey.UpdateAdmin && - (a.data as DaoAdminExecData)._actionData![0].data.newAdmin === - currentAddress - ) - const existingManageSubDaosIndex = - outerCrossChainExecuteIndex !== undefined - ? ( - getValues( - parentActionDataListFieldName as any - ) as ActionKeyAndData[] - ).findIndex( - (a, i) => - i > outerCrossChainExecuteIndex && - a.actionKey === ActionKey.ManageSubDaos - ) - : allActionsWithData.findIndex( - (a, i) => i > index && a.actionKey === ActionKey.ManageSubDaos - ) - - if (existingUpdateAdminIndex === -1) { - addAction( - { - actionKey: ActionKey.DaoAdminExec, - data: { - chainId, - coreAddress: address, - _actionData: [ - { - actionKey: ActionKey.UpdateAdmin, - data: { - chainId, - contract: address, - newAdmin: currentAddress, - } as UpdateAdminData, - }, - ], - } as DaoAdminExecData, - }, - // After current action. - index + 1 - ) - } else { - // Prefix to the fields on the update admin sub-action of the DAO admin - // exec action. - const existingActionPrefix = fieldNamePrefix.replace( - new RegExp(`${index}\\.data\\.$`), - `${existingUpdateAdminIndex}.data.` - ) - - // If the fields aren't correct, update the existing one. - if (getValues((existingActionPrefix + 'chainId') as any) !== chainId) { - setValue((existingActionPrefix + 'chainId') as any, chainId) - } - if ( - getValues((existingActionPrefix + 'coreAddress') as any) !== address - ) { - setValue((existingActionPrefix + 'coreAddress') as any, address) - } - if ( - getValues( - (existingActionPrefix + '_actionData.0.data.chainId') as any - ) !== chainId - ) { - setValue( - (existingActionPrefix + '_actionData.0.data.chainId') as any, - chainId - ) - } - if ( - getValues( - (existingActionPrefix + '_actionData.0.data.contract') as any - ) !== address - ) { - setValue( - (existingActionPrefix + '_actionData.0.data.contract') as any, - address - ) - } - if ( - getValues( - (existingActionPrefix + '_actionData.0.data.newAdmin') as any - ) !== currentAddress - ) { - setValue( - (existingActionPrefix + '_actionData.0.data.newAdmin') as any, - currentAddress - ) - } - } - - if (existingManageSubDaosIndex === -1) { - // If within cross-chain execute action, add Manage SubDAOs action after - // the cross-chain execute action in the parent context. - if (outerCrossChainExecuteIndex !== undefined) { - setValue( - parentActionDataListFieldName as any, - ( - getValues( - parentActionDataListFieldName as any - ) as ActionKeyAndData[] - ).flatMap((existing, index) => [ - existing, - // If this is the cross-chain execute action, insert manage subDAOs - // action after. - ...(index === outerCrossChainExecuteIndex - ? [ - { - _id: uuidv4(), - actionKey: ActionKey.ManageSubDaos, - data: { - toAdd: [ - { - addr: address, - }, - ], - toRemove: [], - } as ManageSubDaosData, - }, - ] - : []), - ]) - ) - } else { - addAction( - { - actionKey: ActionKey.ManageSubDaos, - data: { - toAdd: [ - { - addr: address, - }, - ], - toRemove: [], - } as ManageSubDaosData, - }, - // After DAO admin exec / update admin action. - index + 2 - ) - } - } else { - // Path to the address field on the manage subDAOs action. - const existingAddressFieldName = - outerCrossChainExecuteIndex !== undefined - ? parentActionDataListFieldName + - `.${existingManageSubDaosIndex}.data.toAdd.0.addr` - : fieldNamePrefix.replace( - new RegExp(`${index}\\.data\\.$`), - `${existingManageSubDaosIndex}.data.toAdd.0.addr` - ) - - // If the address isn't correct, update the existing one. - if (getValues(existingAddressFieldName as any) !== address) { - setValue(existingAddressFieldName as any, address) - } - } - - setOtherActionsAdded(true) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - isCreating, - isValid, - address, - index, - addAction, - currentAddress, - chainId, - fieldNamePrefix, - getValues, - setValue, - ]) - - return ( - <> -
- - - -
- - {otherActionsAdded && ( -

- {t('info.acceptSubDaoActionOtherActionsAdded')} -

- )} - - ) -} diff --git a/packages/stateful/actions/core/subdaos/AcceptSubDao/index.tsx b/packages/stateful/actions/core/subdaos/AcceptSubDao/index.tsx deleted file mode 100644 index cebb0771c..000000000 --- a/packages/stateful/actions/core/subdaos/AcceptSubDao/index.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { useFormContext } from 'react-hook-form' -import { useTranslation } from 'react-i18next' - -import { - ChainProvider, - CheckEmoji, - DaoSupportedChainPickerInput, -} from '@dao-dao/stateless' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { - decodePolytoneExecuteMsg, - getChainAddressForActionOptions, - makeExecuteSmartContractMessage, - maybeMakePolytoneExecuteMessage, -} from '@dao-dao/utils' - -import { AddressInput } from '../../../../components' -import { useActionOptions } from '../../../react' -import { CrossChainExecuteData } from '../../advanced/CrossChainExecute/Component' -import { AcceptSubDaoComponent, AcceptSubDaoData } from './Component' - -const useDefaults: UseDefaults = () => ({ - chainId: useActionOptions().chain.chain_id, - address: '', -}) - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - let chainId = useActionOptions().chain.chain_id - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - } - - try { - const match = Boolean(msg.wasm.execute.msg.accept_admin_nomination) - - if (match) { - return { - match, - data: { - chainId, - address: msg.wasm.execute.contract_addr, - }, - } - } - - return { match: false } - } catch (e) { - return { match: false } - } -} - -const useTransformToCosmos: UseTransformToCosmos = () => { - const options = useActionOptions() - - return ({ chainId, address }) => - maybeMakePolytoneExecuteMessage( - options.chain.chain_id, - chainId, - makeExecuteSmartContractMessage({ - chainId, - sender: getChainAddressForActionOptions(options, chainId) || '', - contractAddress: address, - msg: { - accept_admin_nomination: {}, - }, - }) - ) -} - -const Component: ActionComponent = (props) => { - const { t } = useTranslation() - const { - context, - chain: { chain_id: nativeChainId }, - } = useActionOptions() - const { watch } = useFormContext() - const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') - const address = watch((props.fieldNamePrefix + 'address') as 'address') - - return ( - <> - {props.isCreating && ( -

{t('info.acceptSubDaoActionDescription')}

- )} - - {context.type === ActionContextType.Dao && ( - { - if (newChainId !== nativeChainId) { - props.remove() - props.addAction( - { - actionKey: ActionKey.CrossChainExecute, - data: { - chainId: newChainId, - _actionData: [ - { - actionKey: ActionKey.AcceptSubDao, - data: { - chainId: newChainId, - address, - } as AcceptSubDaoData, - }, - ], - } as CrossChainExecuteData, - }, - props.index - ) - } - } - : undefined - } - onlyDaoChainIds - /> - )} - - - - - - ) -} - -export const makeAcceptSubDaoAction: ActionMaker = ({ - t, -}) => ({ - key: ActionKey.AcceptSubDao, - Icon: CheckEmoji, - label: t('title.acceptSubDao'), - description: t('info.acceptSubDaoDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, -}) diff --git a/packages/stateful/actions/core/subdaos/BecomeSubDao/index.tsx b/packages/stateful/actions/core/subdaos/BecomeSubDao/index.tsx deleted file mode 100644 index 238aa5166..000000000 --- a/packages/stateful/actions/core/subdaos/BecomeSubDao/index.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { BabyEmoji } from '@dao-dao/stateless' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, -} from '@dao-dao/types/actions' -import { makeWasmMessage } from '@dao-dao/utils' - -import { AddressInput } from '../../../../components' -import { BecomeSubDaoComponent, BecomeSubDaoData } from './Component' - -const useDefaults: UseDefaults = () => ({ - admin: '', -}) - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - try { - return { - match: true, - data: { - admin: msg.wasm.execute.msg.nominate_admin.admin, - }, - } - } catch { - return { match: false } - } -} - -const Component: ActionComponent = (props) => { - return -} - -export const makeBecomeSubDaoAction: ActionMaker = ({ - t, - address, - context, -}) => { - function useTransformToCosmos() { - return ({ admin }: { admin: string }) => - makeWasmMessage({ - wasm: { - execute: { - contract_addr: address, - funds: [], - msg: { - nominate_admin: { - admin, - }, - }, - }, - }, - }) - } - - return { - key: ActionKey.BecomeSubDao, - Icon: BabyEmoji, - label: t('title.becomeSubDao'), - description: t('info.becomeSubDaoDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - notReusable: true, - // If parent DAO exists, hide this action. - hideFromPicker: - context.type === ActionContextType.Dao && - context.dao.info.parentDao !== null, - } -} diff --git a/packages/stateful/actions/core/subdaos/ManageSubDaos/index.tsx b/packages/stateful/actions/core/subdaos/ManageSubDaos/index.tsx deleted file mode 100644 index 701b1412d..000000000 --- a/packages/stateful/actions/core/subdaos/ManageSubDaos/index.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { useCallback } from 'react' -import { useRecoilValue } from 'recoil' - -import { DaoDaoCoreSelectors } from '@dao-dao/state' -import { FamilyEmoji } from '@dao-dao/stateless' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - Feature, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types' -import { makeWasmMessage, objectMatchesStructure } from '@dao-dao/utils' - -import { AddressInput, EntityDisplay } from '../../../../components' -import { useActionOptions } from '../../../react' -import { - ManageSubDaosData, - ManageSubDaosComponent as StatelessManageSubDaosComponent, -} from './Component' - -const useDefaults: UseDefaults = () => ({ - toAdd: [ - { - addr: '', - }, - ], - toRemove: [], -}) - -const Component: ActionComponent = (props) => { - const { - address, - chain: { chain_id: chainId }, - } = useActionOptions() - - const currentSubDaos = useRecoilValue( - DaoDaoCoreSelectors.allSubDaoConfigsSelector({ - chainId, - contractAddress: address, - }) - ) - - return ( - - ) -} - -export const makeManageSubDaosAction: ActionMaker = ({ - t, - address, - context, -}) => { - if ( - context.type !== ActionContextType.Dao || - !context.dao.info.supportedFeatures[Feature.SubDaos] - ) { - return null - } - - const useTransformToCosmos: UseTransformToCosmos = () => - useCallback( - ({ toAdd, toRemove }) => - makeWasmMessage({ - wasm: { - execute: { - contract_addr: address, - funds: [], - msg: { - update_sub_daos: { - to_add: toAdd, - to_remove: toRemove.map(({ address }) => address), - }, - }, - }, - }, - }), - [] - ) - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => - objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - update_sub_daos: { - to_add: {}, - to_remove: {}, - }, - }, - }, - }, - }) && msg.wasm.execute.contract_addr === address - ? { - match: true, - data: { - toAdd: msg.wasm.execute.msg.update_sub_daos.to_add, - toRemove: msg.wasm.execute.msg.update_sub_daos.to_remove.map( - (address: string) => ({ - address, - }) - ), - }, - } - : { - match: false, - } - - return { - key: ActionKey.ManageSubDaos, - Icon: FamilyEmoji, - label: t('title.manageSubDaos'), - description: t('info.manageSubDaosActionDescription'), - notReusable: true, - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - } -} diff --git a/packages/stateful/actions/core/treasury/CommunityPoolDeposit/index.tsx b/packages/stateful/actions/core/treasury/CommunityPoolDeposit/index.tsx deleted file mode 100644 index 4b3dcc55e..000000000 --- a/packages/stateful/actions/core/treasury/CommunityPoolDeposit/index.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { coins } from '@cosmjs/stargate' -import { useCallback } from 'react' -import { useFormContext } from 'react-hook-form' - -import { genericTokenSelector } from '@dao-dao/state/recoil' -import { DownArrowEmoji, useCachedLoadingWithError } from '@dao-dao/stateless' -import { - ChainId, - TokenType, - UseDecodedCosmosMsg, - makeStargateMessage, -} from '@dao-dao/types' -import { - ActionComponent, - ActionKey, - ActionMaker, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { MsgFundCommunityPool } from '@dao-dao/types/protobuf/codegen/cosmos/distribution/v1beta1/tx' -import { - convertDenomToMicroDenomStringWithDecimals, - convertMicroDenomToDenomWithDecimals, - decodePolytoneExecuteMsg, - isDecodedStargateMsg, - maybeMakePolytoneExecuteMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { useTokenBalances } from '../../../hooks' -import { - CommunityPoolDepositComponent, - CommunityPoolDepositData, -} from './Component' - -const Component: ActionComponent = ( - props -) => { - const { watch } = useFormContext() - - const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') - const denom = watch((props.fieldNamePrefix + 'denom') as 'denom') - - const tokens = useTokenBalances({ - filter: TokenType.Native, - // Load selected token when not creating, in case it is no longer returned - // in the list of all tokens for the given account. - additionalTokens: props.isCreating - ? undefined - : [ - { - chainId, - type: TokenType.Native, - denomOrAddress: denom, - }, - ], - }) - - return ( - - ) -} - -export const makeCommunityPoolDepositAction: ActionMaker< - CommunityPoolDepositData -> = ({ t, address, chain: { chain_id: currentChainId }, chainContext }) => { - // Neutron does not use the x/distribution community pool. - if ( - currentChainId === ChainId.NeutronMainnet || - currentChainId === ChainId.NeutronTestnet - ) { - return null - } - - const useDefaults: UseDefaults = () => ({ - chainId: currentChainId, - amount: 100, - denom: chainContext.nativeToken?.denomOrAddress || '', - }) - - const useTransformToCosmos: UseTransformToCosmos< - CommunityPoolDepositData - > = () => { - const tokens = useTokenBalances({ - filter: TokenType.Native, - }) - - return useCallback( - ({ chainId, amount, denom }: CommunityPoolDepositData) => { - if (tokens.loading) { - return - } - - const token = tokens.data.find( - ({ token }) => - token.chainId === chainId && token.denomOrAddress === denom - )?.token - if (!token) { - throw new Error(`Unknown token: ${denom}`) - } - - return maybeMakePolytoneExecuteMessage( - currentChainId, - chainId, - makeStargateMessage({ - stargate: { - typeUrl: MsgFundCommunityPool.typeUrl, - value: MsgFundCommunityPool.fromPartial({ - depositor: address, - amount: coins( - convertDenomToMicroDenomStringWithDecimals( - amount, - token.decimals - ), - denom - ), - }), - }, - }) - ) - }, - [tokens] - ) - } - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - let chainId = currentChainId - const decodedPolytone = decodePolytoneExecuteMsg(currentChainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - } - - const isFundMsg = - isDecodedStargateMsg(msg) && - objectMatchesStructure(msg, { - stargate: { - typeUrl: {}, - value: { - depositor: {}, - amount: {}, - }, - }, - }) && - msg.stargate.typeUrl === MsgFundCommunityPool.typeUrl && - msg.stargate.value.depositor === address && - Array.isArray(msg.stargate.value.amount) && - msg.stargate.value.amount.length === 1 - - const token = useCachedLoadingWithError( - isFundMsg - ? genericTokenSelector({ - chainId, - type: TokenType.Native, - denomOrAddress: msg.stargate.value.amount[0].denom, - }) - : undefined - ) - - if (!isFundMsg || token.loading || token.errored) { - return { match: false } - } - - return { - match: true, - data: { - chainId, - amount: convertMicroDenomToDenomWithDecimals( - msg.stargate.value.amount[0].amount, - token.data.decimals - ), - denom: msg.stargate.value.amount[0].denom, - }, - } - } - - return { - key: ActionKey.CommunityPoolDeposit, - Icon: DownArrowEmoji, - label: t('title.communityPoolDeposit'), - description: t('info.communityPoolDepositDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - } -} diff --git a/packages/stateful/actions/core/treasury/CommunityPoolSpend/index.tsx b/packages/stateful/actions/core/treasury/CommunityPoolSpend/index.tsx deleted file mode 100644 index 187e864da..000000000 --- a/packages/stateful/actions/core/treasury/CommunityPoolSpend/index.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { useCallback } from 'react' - -import { MoneyEmoji } from '@dao-dao/stateless' -import { UseDecodedCosmosMsg, makeStargateMessage } from '@dao-dao/types' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { MsgCommunityPoolSpend } from '@dao-dao/types/protobuf/codegen/cosmos/distribution/v1beta1/tx' -import { isDecodedStargateMsg, objectMatchesStructure } from '@dao-dao/utils' - -import { PayEntityDisplay } from '../../../../components/PayEntityDisplay' -import { - CommunityPoolSpendComponent, - CommunityPoolSpendData, -} from './Component' - -const useDefaults: UseDefaults = () => ({ - authority: '', - recipient: '', - funds: [], -}) - -const Component: ActionComponent = ( - props -) => ( - -) - -const useTransformToCosmos: UseTransformToCosmos = () => - useCallback( - ({ authority, recipient, funds }: CommunityPoolSpendData) => - makeStargateMessage({ - stargate: { - typeUrl: MsgCommunityPoolSpend.typeUrl, - value: MsgCommunityPoolSpend.fromPartial({ - authority, - recipient, - amount: funds, - }), - }, - }), - [] - ) - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => - isDecodedStargateMsg(msg) && - objectMatchesStructure(msg.stargate.value, { - authority: {}, - recipient: {}, - amount: {}, - }) && - msg.stargate.typeUrl === MsgCommunityPoolSpend.typeUrl - ? { - match: true, - data: { - authority: msg.stargate.value.authority, - recipient: msg.stargate.value.recipient, - funds: msg.stargate.value.amount, - }, - } - : { - match: false, - } - -export const makeCommunityPoolSpendAction: ActionMaker< - CommunityPoolSpendData -> = ({ t, context }) => - context.type === ActionContextType.Gov - ? { - key: ActionKey.CommunityPoolSpend, - Icon: MoneyEmoji, - label: t('title.spend'), - description: t('info.spendActionDescription', { - context: context.type, - }), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - // The normal Spend action will automatically create community pool - // spends when used inside a governance proposal context. This community - // pool spend action is just for display purposes, since the Spend - // action only allows selecting one token at a time, whereas community - // pool spends can contain multiple tokens. Thus, don't allow choosing - // this action when creating a proposal, but still render it. - hideFromPicker: true, - } - : null diff --git a/packages/stateful/actions/core/treasury/ConfigureVestingPayments/index.tsx b/packages/stateful/actions/core/treasury/ConfigureVestingPayments/index.tsx deleted file mode 100644 index 523d77d7a..000000000 --- a/packages/stateful/actions/core/treasury/ConfigureVestingPayments/index.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import cloneDeep from 'lodash.clonedeep' -import { useCallback } from 'react' - -import { SuitAndTieEmoji } from '@dao-dao/stateless' -import { VestingPaymentsWidgetData, WidgetId } from '@dao-dao/types' -import { - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { getWidgetStorageItemKey } from '@dao-dao/utils' - -import { useWidgets } from '../../../../widgets' -import { makeManageWidgetsAction } from '../../dao_appearance/ManageWidgets' -import { ConfigureVestingPaymentsComponent as Component } from './Component' - -export const makeConfigureVestingPaymentsAction: ActionMaker< - VestingPaymentsWidgetData -> = (options) => { - const { t, context } = options - - const manageWidgetsAction = makeManageWidgetsAction(options) - if (context.type !== ActionContextType.Dao || !manageWidgetsAction) { - return null - } - - const useDefaults: UseDefaults = () => { - // Attempt to load existing widget data. - const loadingExistingWidgets = useWidgets() - const widget = loadingExistingWidgets.loading - ? undefined - : loadingExistingWidgets.data.find( - ({ widget: { id } }) => id === WidgetId.VestingPayments - ) - - return widget - ? cloneDeep(widget.daoWidget.values) - : { - factories: {}, - } - } - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - const decoded = manageWidgetsAction.useDecodedCosmosMsg(msg) - - return decoded.match && - decoded.data.mode === 'set' && - decoded.data.id === WidgetId.VestingPayments - ? { - match: true, - data: decoded.data.values as VestingPaymentsWidgetData, - } - : { - match: false, - } - } - - const useTransformToCosmos: UseTransformToCosmos< - VestingPaymentsWidgetData - > = () => { - const transform = manageWidgetsAction.useTransformToCosmos() - - return useCallback( - (data) => - transform({ - mode: 'set', - id: WidgetId.VestingPayments, - values: data, - }), - [transform] - ) - } - - const vestingEnabled = - !!context.dao.info.items[getWidgetStorageItemKey(WidgetId.VestingPayments)] - - return { - key: ActionKey.ConfigureVestingPayments, - Icon: SuitAndTieEmoji, - label: vestingEnabled - ? t('title.configureVestingPayments') - : t('title.enableVestingPayments'), - description: vestingEnabled - ? t('info.configureVestingPaymentsDescription') - : t('widgetDescription.vesting'), - keywords: ['payroll'], - notReusable: true, - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - } -} diff --git a/packages/stateful/actions/core/treasury/EnableRetroactiveCompensation/index.tsx b/packages/stateful/actions/core/treasury/EnableRetroactiveCompensation/index.tsx deleted file mode 100644 index 99491b634..000000000 --- a/packages/stateful/actions/core/treasury/EnableRetroactiveCompensation/index.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useCallback } from 'react' - -import { BeeEmoji } from '@dao-dao/stateless' -import { - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { getWidgetStorageItemKey } from '@dao-dao/utils' - -import { RetroactiveCompensationWidget } from '../../../../widgets/widgets/RetroactiveCompensation' -import { makeManageWidgetsAction } from '../../dao_appearance/ManageWidgets' -import { EnableRetroactiveCompensationComponent as Component } from './Component' - -const useDefaults: UseDefaults = () => ({}) - -export const makeEnableRetroactiveCompensationAction: ActionMaker = ( - options -) => { - const { t, context } = options - - const manageWidgetsAction = makeManageWidgetsAction(options) - if (context.type !== ActionContextType.Dao || !manageWidgetsAction) { - return null - } - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - const decoded = manageWidgetsAction.useDecodedCosmosMsg(msg) - - return decoded.match && - decoded.data.mode === 'set' && - decoded.data.id === RetroactiveCompensationWidget.id - ? { - match: true, - data: {}, - } - : { - match: false, - } - } - - const useTransformToCosmos: UseTransformToCosmos = () => { - const transform = manageWidgetsAction.useTransformToCosmos() - - return useCallback( - () => - transform({ - mode: 'set', - id: RetroactiveCompensationWidget.id, - values: {}, - }), - [transform] - ) - } - - return { - key: ActionKey.EnableRetroactiveCompensation, - Icon: BeeEmoji, - label: t('title.enableRetroactiveCompensation'), - description: t('widgetDescription.retroactive'), - keywords: ['payroll'], - notReusable: true, - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - // Do not allow using this action if the DAO already has retroactive - // compensation enabled. - hideFromPicker: - !!context.dao.info.items[ - getWidgetStorageItemKey(RetroactiveCompensationWidget.id) - ], - } -} diff --git a/packages/stateful/actions/core/treasury/ManageCw20/index.tsx b/packages/stateful/actions/core/treasury/ManageCw20/index.tsx deleted file mode 100644 index d71c991cc..000000000 --- a/packages/stateful/actions/core/treasury/ManageCw20/index.tsx +++ /dev/null @@ -1,302 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' -import { useFormContext } from 'react-hook-form' -import { useTranslation } from 'react-i18next' -import { - constSelector, - useRecoilValue, - useRecoilValueLoadable, - waitForAll, -} from 'recoil' - -import { Cw20BaseSelectors } from '@dao-dao/state' -import { DaoDaoCoreSelectors } from '@dao-dao/state/recoil' -import { TokenEmoji } from '@dao-dao/stateless' -import { Feature } from '@dao-dao/types' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { TokenInfoResponse } from '@dao-dao/types/contracts/Cw20Base' -import { - POLYTONE_CW20_ITEM_KEY_PREFIX, - getChainForChainId, - isValidBech32Address, - makeWasmMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { useActionOptions } from '../../../react' -import { - ManageCw20Data, - ManageCw20Component as StatelessManageCw20Component, -} from './Component' - -const useDefaults: UseDefaults = () => { - const { - chain: { chain_id: chainId }, - } = useActionOptions() - - return { - chainId, - adding: true, - address: '', - } -} - -const Component: ActionComponent = (props) => { - const { - address, - chain: { chain_id: currentChainId }, - } = useActionOptions() - - const { t } = useTranslation() - const { fieldNamePrefix } = props - - const { watch } = useFormContext() - - const chainId = watch((fieldNamePrefix + 'chainId') as 'chainId') - const { bech32_prefix: bech32Prefix } = getChainForChainId(chainId) - - const adding = watch(fieldNamePrefix + 'adding') - const tokenAddress = watch(fieldNamePrefix + 'address') - - const tokenInfoLoadable = useRecoilValueLoadable( - tokenAddress && isValidBech32Address(tokenAddress, bech32Prefix) - ? Cw20BaseSelectors.tokenInfoSelector({ - contractAddress: tokenAddress, - chainId, - params: [], - }) - : constSelector(undefined) - ) - - const existingTokenAddresses = useRecoilValue( - DaoDaoCoreSelectors.allCw20TokensSelector({ - contractAddress: address, - chainId: currentChainId, - }) - )[chainId]?.tokens - const existingTokenInfos = useRecoilValue( - waitForAll( - existingTokenAddresses?.map((token) => - Cw20BaseSelectors.tokenInfoSelector({ - contractAddress: token, - chainId, - params: [], - }) - ) ?? [] - ) - ) - const existingTokens = useMemo( - () => - (existingTokenAddresses - ?.map((address, idx) => ({ - address, - info: existingTokenInfos[idx], - })) - // If undefined token info response, ignore the token. - .filter(({ info }) => !!info) ?? []) as { - address: string - info: TokenInfoResponse - }[], - [existingTokenAddresses, existingTokenInfos] - ) - - const [additionalAddressError, setAdditionalAddressError] = useState() - useEffect(() => { - const tokenInfoErrored = tokenInfoLoadable.state === 'hasError' - const noTokensWhenRemoving = !adding && existingTokens.length === 0 - - if (!tokenInfoErrored && !noTokensWhenRemoving) { - if (additionalAddressError) { - setAdditionalAddressError(undefined) - } - return - } - - if (!additionalAddressError) { - setAdditionalAddressError( - tokenInfoErrored - ? t('error.notCw20Address') - : noTokensWhenRemoving - ? t('error.noCw20Tokens') - : // Should never happen. - t('error.unexpectedError') - ) - } - }, [ - tokenInfoLoadable.state, - existingTokens.length, - t, - additionalAddressError, - adding, - ]) - - return ( - - ) -} - -export const makeManageCw20Action: ActionMaker = ({ - t, - address, - context, - chain: { chain_id: chainId }, -}) => { - // Only DAOs. - if (context.type !== ActionContextType.Dao) { - return null - } - - const storageItemValueKey = context.dao.info.supportedFeatures[ - Feature.StorageItemValueKey - ] - ? 'value' - : 'addr' - - const useTransformToCosmos: UseTransformToCosmos = () => - useCallback( - (data: ManageCw20Data) => - makeWasmMessage({ - wasm: { - execute: { - contract_addr: address, - funds: [], - // If adding for polytone proxy (on other chain), use items - // instead of the core contract message. - msg: - data.chainId !== chainId - ? data.adding - ? { - set_item: { - key: - POLYTONE_CW20_ITEM_KEY_PREFIX + - data.chainId + - ':' + - data.address, - [storageItemValueKey]: '1', - }, - } - : { - remove_item: { - key: - POLYTONE_CW20_ITEM_KEY_PREFIX + - data.chainId + - ':' + - data.address, - }, - } - : { - update_cw20_list: { - to_add: data.adding ? [data.address] : [], - to_remove: !data.adding ? [data.address] : [], - }, - }, - }, - }, - }), - [] - ) - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - if ( - objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - update_cw20_list: { - to_add: {}, - to_remove: {}, - }, - }, - }, - }, - }) && - msg.wasm.execute.contract_addr === address && - // Ensure only one token is being added or removed, but not both, and not - // more than one token. Ideally this component lets you add or remove - // multiple tokens at once, but that's not supported yet. - ((msg.wasm.execute.msg.update_cw20_list.to_add.length === 1 && - msg.wasm.execute.msg.update_cw20_list.to_remove.length === 0) || - (msg.wasm.execute.msg.update_cw20_list.to_add.length === 0 && - msg.wasm.execute.msg.update_cw20_list.to_remove.length === 1)) - ) { - return { - match: true, - data: { - chainId, - adding: msg.wasm.execute.msg.update_cw20_list.to_add.length === 1, - address: - msg.wasm.execute.msg.update_cw20_list.to_add.length === 1 - ? msg.wasm.execute.msg.update_cw20_list.to_add[0] - : msg.wasm.execute.msg.update_cw20_list.to_remove[0], - }, - } - } - - if ( - objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: {}, - }, - }, - }) && - msg.wasm.execute.contract_addr === address && - ('set_item' in msg.wasm.execute.msg || - 'remove_item' in msg.wasm.execute.msg) - ) { - const adding = 'set_item' in msg.wasm.execute.msg - const key = - (adding - ? msg.wasm.execute.msg.set_item.key - : msg.wasm.execute.msg.remove_item.key) ?? '' - - if (key.startsWith(POLYTONE_CW20_ITEM_KEY_PREFIX)) { - // format is `prefix:[chainId]:[address]` - return { - match: true, - data: { - chainId: key.split(':')[1], - adding, - address: key.split(':')[2], - }, - } - } - } - - return { match: false } - } - - return { - key: ActionKey.ManageCw20, - Icon: TokenEmoji, - label: t('title.manageTreasuryTokens'), - description: t('info.manageTreasuryTokensDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - } -} diff --git a/packages/stateful/actions/core/treasury/ManageVesting/index.tsx b/packages/stateful/actions/core/treasury/ManageVesting/index.tsx deleted file mode 100644 index 977085107..000000000 --- a/packages/stateful/actions/core/treasury/ManageVesting/index.tsx +++ /dev/null @@ -1,1166 +0,0 @@ -import { coins } from '@cosmjs/amino' -import { useQueries, useQueryClient } from '@tanstack/react-query' -import { ComponentType, useCallback, useEffect } from 'react' -import { useFormContext } from 'react-hook-form' -import { useTranslation } from 'react-i18next' -import { constSelector, useRecoilValueLoadable, waitForAll } from 'recoil' - -import { - cwPayrollFactoryQueries, - cwVestingExtraQueries, -} from '@dao-dao/state/query' -import { - Cw1WhitelistSelectors, - genericTokenSelector, - nativeUnstakingDurationSecondsSelector, -} from '@dao-dao/state/recoil' -import { - Loader, - MoneyWingsEmoji, - SegmentedControls, - useCachedLoadable, -} from '@dao-dao/stateless' -import { - DurationUnits, - DurationWithUnits, - SegmentedControlsProps, - TokenType, - TypedOption, - UnifiedCosmosMsg, - VestingContractVersion, - VestingInfo, - VestingPaymentsWidgetData, - WidgetId, -} from '@dao-dao/types' -import { - ActionComponent, - ActionComponentProps, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseHideFromPicker, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { - ExecuteMsg, - InstantiateNativePayrollContractMsg, -} from '@dao-dao/types/contracts/CwPayrollFactory' -import { InstantiateMsg as VestingInstantiateMsg } from '@dao-dao/types/contracts/CwVesting' -import { - convertDenomToMicroDenomWithDecimals, - convertDurationWithUnitsToSeconds, - convertMicroDenomToDenomWithDecimals, - convertSecondsToDurationWithUnits, - decodeCw1WhitelistExecuteMsg, - decodeJsonFromBase64, - decodePolytoneExecuteMsg, - encodeJsonToBase64, - getChainAddressForActionOptions, - getDisplayNameForChainId, - getNativeTokenForChainId, - isValidBech32Address, - makeCombineQueryResultsIntoLoadingDataWithError, - makeWasmMessage, - maybeMakePolytoneExecuteMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { - AddressInput, - EntityDisplay, - SuspenseLoader, - Trans, - VestingPaymentCard, -} from '../../../../components' -import { - useCreateCw1Whitelist, - useQueryLoadingData, - useQueryLoadingDataWithError, -} from '../../../../hooks' -import { useWidget } from '../../../../widgets' -import { useTokenBalances } from '../../../hooks/useTokenBalances' -import { useActionOptions } from '../../../react' -import { BeginVesting, BeginVestingData } from './BeginVesting' -import { CancelVesting, CancelVestingData } from './CancelVesting' -import { RegisterSlash, RegisterSlashData } from './RegisterSlash' - -export type ManageVestingData = { - mode: 'begin' | 'cancel' | 'registerSlash' - begin: BeginVestingData - cancel: CancelVestingData - registerSlash: RegisterSlashData -} - -const instantiateStructure = { - instantiate_msg: { - denom: {}, - recipient: {}, - schedule: {}, - title: {}, - total: {}, - unbonding_duration_seconds: {}, - vesting_duration_seconds: {}, - }, - label: {}, -} - -/** - * Get the vesting infos owned by (and thus can be canceled by) the current - * entity executing an action. These may or may not have been created by the - * current entity, since someone can set another entity as an owner/canceler of - * a vesting contract. - */ -const useVestingInfosOwnedByEntity = () => { - const { - context, - address: nativeAddress, - chain: { chain_id: nativeChainId }, - } = useActionOptions() - const queryClient = useQueryClient() - - return useQueries({ - queries: - context.type === ActionContextType.Dao - ? // Get vesting infos owned by any of the DAO's accounts. - context.dao.accounts.map(({ chainId, address }) => - cwVestingExtraQueries.vestingInfosOwnedBy(queryClient, { - address, - chainId, - }) - ) - : [ - cwVestingExtraQueries.vestingInfosOwnedBy(queryClient, { - address: nativeAddress, - chainId: nativeChainId, - }), - ], - combine: makeCombineQueryResultsIntoLoadingDataWithError({ - transform: (infos) => infos.flat(), - }), - }) -} - -const Component: ComponentType< - ActionComponentProps & { - widgetData?: VestingPaymentsWidgetData - } -> = ({ widgetData, ...props }) => { - const { t } = useTranslation() - const { - chain: { chain_id: nativeChainId }, - } = useActionOptions() - - const { setValue, watch, setError, clearErrors, trigger } = - useFormContext() - const mode = watch((props.fieldNamePrefix + 'mode') as 'mode') - const selectedChainId = - mode === 'begin' - ? watch((props.fieldNamePrefix + 'begin.chainId') as 'begin.chainId') - : mode === 'registerSlash' - ? watch( - (props.fieldNamePrefix + - 'registerSlash.chainId') as 'registerSlash.chainId' - ) - : mode === 'cancel' - ? watch((props.fieldNamePrefix + 'cancel.chainId') as 'cancel.chainId') - : undefined - const selectedAddress = - mode === 'registerSlash' - ? watch( - (props.fieldNamePrefix + - 'registerSlash.address') as 'registerSlash.address' - ) - : mode === 'cancel' - ? watch((props.fieldNamePrefix + 'cancel.address') as 'cancel.address') - : undefined - const beginOwnerMode = watch( - (props.fieldNamePrefix + 'begin.ownerMode') as 'begin.ownerMode' - ) - const beginManyOwnersCw1WhitelistContract = watch( - (props.fieldNamePrefix + - 'begin.manyOwnersCw1WhitelistContract') as 'begin.manyOwnersCw1WhitelistContract' - ) - - const tokenBalances = useTokenBalances() - - // Only used on pre-v1 vesting widgets. - const queryClient = useQueryClient() - const preV1VestingFactoryOwner = useQueryLoadingDataWithError( - widgetData && !widgetData.version && widgetData.factory - ? cwPayrollFactoryQueries.ownership(queryClient, { - chainId: nativeChainId, - contractAddress: widgetData.factory, - }) - : undefined, - ({ owner }) => owner || null - ) - - const vestingInfos = useVestingInfosOwnedByEntity() - - const didSelectVest = - !props.isCreating && - (mode === 'registerSlash' || mode === 'cancel') && - !!selectedChainId && - !!selectedAddress - const selectedVest = useQueryLoadingData( - didSelectVest - ? cwVestingExtraQueries.info(queryClient, { - chainId: selectedChainId, - address: selectedAddress, - }) - : undefined, - undefined as VestingInfo | undefined - ) - - // Prevent action from being submitted if no address is selected while we're - // registering slash or cancelling. - useEffect(() => { - if (mode !== 'registerSlash' && mode !== 'cancel') { - clearErrors( - (props.fieldNamePrefix + - 'registerSlash.address') as 'registerSlash.address' - ) - clearErrors( - (props.fieldNamePrefix + 'cancel.address') as 'cancel.address' - ) - return - } - // Make sure to clear errors for other modes on switch. - else if (mode === 'registerSlash') { - clearErrors( - (props.fieldNamePrefix + 'cancel.address') as 'cancel.address' - ) - } else if (mode === 'cancel') { - clearErrors( - (props.fieldNamePrefix + - 'registerSlash.address') as 'registerSlash.address' - ) - } - - if (!selectedAddress || !isValidBech32Address(selectedAddress)) { - setError( - (props.fieldNamePrefix + `${mode}.address`) as `${typeof mode}.address`, - { - type: 'manual', - message: t('error.noVestingContractSelected'), - } - ) - } else { - clearErrors( - (props.fieldNamePrefix + `${mode}.address`) as `${typeof mode}.address` - ) - } - }, [setError, clearErrors, props.fieldNamePrefix, t, mode, selectedAddress]) - - const tabs: SegmentedControlsProps['tabs'] = [ - // Only allow beginning a vest if widget is setup. - ...(widgetData - ? ([ - { - label: t('title.beginVesting'), - value: 'begin', - }, - ] as TypedOption[]) - : []), - { - label: t('title.cancelVesting'), - value: 'cancel', - }, - { - label: t('title.registerSlash'), - value: 'registerSlash', - }, - ] - const selectedTab = tabs.find((tab) => tab.value === mode) - - const { - creatingCw1Whitelist: creatingCw1WhitelistOwners, - createCw1Whitelist: createCw1WhitelistOwners, - } = useCreateCw1Whitelist({ - // Trigger veto address field validations. - validation: async () => { - if (beginOwnerMode !== 'many') { - throw new Error(t('error.unexpectedError')) - } - - await trigger( - (props.fieldNamePrefix + 'begin.manyOwners') as 'begin.manyOwners', - { - shouldFocus: true, - } - ) - }, - contractLabel: 'Vesting Multi-Owner cw1-whitelist', - }) - - // Prevent action from being submitted if the cw1-whitelist contract has not - // yet been created and it needs to be. - useEffect(() => { - if (beginOwnerMode === 'many' && !beginManyOwnersCw1WhitelistContract) { - setError( - (props.fieldNamePrefix + - 'begin.manyOwnersCw1WhitelistContract') as 'begin.manyOwnersCw1WhitelistContract', - { - type: 'manual', - message: t('error.accountListNeedsSaving'), - } - ) - } else { - clearErrors( - (props.fieldNamePrefix + - 'begin.manyOwnersCw1WhitelistContract') as 'begin.manyOwnersCw1WhitelistContract' - ) - } - }, [ - setError, - clearErrors, - t, - beginOwnerMode, - beginManyOwnersCw1WhitelistContract, - props.fieldNamePrefix, - ]) - - return ( - } - forceFallback={ - // Manually trigger loader. - tokenBalances.loading - } - > - {props.isCreating ? ( - - className="mb-2" - onSelect={(value) => - setValue((props.fieldNamePrefix + 'mode') as 'mode', value) - } - selected={mode} - tabs={tabs} - /> - ) : ( -

{selectedTab?.label}

- )} - - {mode === 'begin' ? ( - - ) : mode === 'registerSlash' ? ( - - ) : mode === 'cancel' ? ( - - ) : null} -
- ) -} - -// Only check if widget exists in DAOs. -const DaoComponent: ActionComponent = (props) => { - const widgetData = useWidget( - WidgetId.VestingPayments - )?.daoWidget.values - - return -} - -const WalletComponent: ActionComponent = ( - props -) => - -export const makeManageVestingAction: ActionMaker = ( - options -) => { - const { - t, - context, - address: nativeAddress, - chain: { chain_id: nativeChainId }, - } = options - - // Only available in DAO and wallet contexts. - if ( - context.type !== ActionContextType.Dao && - context.type !== ActionContextType.Wallet - ) { - return null - } - - const makeUseDefaults = - (hasWidgetData: boolean): UseDefaults => - () => { - const { - address, - chain: { chain_id: chainId }, - } = useActionOptions() - - const start = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) - - return { - // Cannot use begin if no widget setup, so default to cancel. - mode: hasWidgetData ? 'begin' : 'cancel', - begin: { - chainId, - amount: 1, - denomOrAddress: getNativeTokenForChainId(chainId).denomOrAddress, - recipient: '', - startDate: `${start.toISOString().split('T')[0]} 12:00 AM`, - title: '', - ownerMode: 'me', - otherOwner: '', - manyOwners: [ - { - address, - }, - { - address: '', - }, - ], - manyOwnersCw1WhitelistContract: '', - steps: [ - { - percent: 100, - delay: { - value: 1, - units: DurationUnits.Years, - }, - }, - ], - }, - cancel: { - chainId, - address: '', - }, - registerSlash: { - chainId, - address: '', - validator: '', - time: '', - amount: '', - duringUnbonding: false, - }, - } - } - - const makeUseTransformToCosmos = ( - widgetData?: VestingPaymentsWidgetData - ): UseTransformToCosmos => { - // Potential chains and owner addresses that the current entity can create a - // new vesting contract from. - const possibleVestingSources = widgetData?.factories - ? Object.entries(widgetData.factories).map( - ([chainId, { address: factory, version }]) => ({ - chainId, - owner: getChainAddressForActionOptions(options, chainId), - factory, - version, - }) - - // If the factories are undefined, this DAO is using an old version of - // the vesting widget which only allows a single factory on the same - // chain as the DAO. If widget data is undefined, this is being used - // by a wallet. - ) - : [ - { - chainId: nativeChainId, - owner: nativeAddress, - factory: widgetData?.factory, - version: widgetData?.version, - }, - ] - - return () => { - const loadingTokenBalances = useTokenBalances() - const queryClient = useQueryClient() - - // Pre-v1 vesting widgets use the factory owner as the vesting owner. - const preV1Vesting = !!widgetData?.factory && !widgetData.version - const preV1VestingFactoryOwner = useQueryLoadingDataWithError( - preV1Vesting - ? cwPayrollFactoryQueries.ownership(queryClient, { - chainId: nativeChainId, - contractAddress: widgetData!.factory!, - }) - : undefined, - ({ owner }) => owner || null - ) - - // Get the native unbonding duration for each chain that a vesting - // contract may be created from. - const nativeUnstakingDurationSecondsLoadable = useRecoilValueLoadable( - waitForAll( - possibleVestingSources.map(({ chainId }) => - nativeUnstakingDurationSecondsSelector({ - chainId, - }) - ) - ) - ) - - // Load all vesting infos owned by the current entity. These may or may - // not have been created by the current entity, since someone can set - // another entity as an owner/canceller of a vesting contract. - const loadingVestingInfos = useVestingInfosOwnedByEntity() - - return useCallback( - ({ mode, begin, registerSlash, cancel }: ManageVestingData) => { - let chainId: string - let cosmosMsg: UnifiedCosmosMsg - - // Can only begin a vest if there is widget data available. - if (mode === 'begin' && widgetData) { - if ( - loadingTokenBalances.loading || - (preV1Vesting && preV1VestingFactoryOwner.loading) || - nativeUnstakingDurationSecondsLoadable.state !== 'hasValue' - ) { - return - } - - const vestingSourceIndex = possibleVestingSources.findIndex( - ({ chainId }) => chainId === begin.chainId - ) - if ( - vestingSourceIndex === -1 || - possibleVestingSources.length !== - nativeUnstakingDurationSecondsLoadable.contents.length - ) { - throw new Error( - t('error.noChainVestingManager', { - chain: getDisplayNameForChainId(begin.chainId), - }) - ) - } - const vestingSource = possibleVestingSources[vestingSourceIndex] - const nativeUnstakingDurationSeconds = - nativeUnstakingDurationSecondsLoadable.contents[ - vestingSourceIndex - ] - - const token = loadingTokenBalances.data.find( - ({ token }) => token.denomOrAddress === begin.denomOrAddress - )?.token - if (!token) { - throw new Error(`Unknown token: ${begin.denomOrAddress}`) - } - - const total = convertDenomToMicroDenomWithDecimals( - begin.amount, - token.decimals - ) - - const vestingDurationSeconds = begin.steps.reduce( - (acc, { delay }) => - acc + convertDurationWithUnitsToSeconds(delay), - 0 - ) - - const instantiateMsg: VestingInstantiateMsg = { - denom: - token.type === TokenType.Native - ? { - native: token.denomOrAddress, - } - : { - cw20: token.denomOrAddress, - }, - description: begin.description || undefined, - owner: - // Widgets prior to V1 use the factory owner. - !vestingSource.version - ? // Default to empty string just in case. This should never be loading here or errored. - (!preV1VestingFactoryOwner.loading && - !preV1VestingFactoryOwner.errored && - preV1VestingFactoryOwner.data) || - '' - : // V1 and later can set the owner, or no widget data (when used by a wallet). - !widgetData || - (vestingSource.version && - vestingSource.version >= VestingContractVersion.V1) - ? begin.ownerMode === 'none' - ? undefined - : begin.ownerMode === 'me' - ? vestingSource.owner - : begin.ownerMode === 'other' - ? begin.otherOwner - : begin.ownerMode === 'many' - ? begin.manyOwnersCw1WhitelistContract - : vestingSource.owner - : vestingSource.owner, - recipient: begin.recipient, - schedule: - begin.steps.length === 1 - ? 'saturating_linear' - : { - piecewise_linear: [ - // First point must be 0 amount at 1 second. - [1, '0'], - ...(begin.steps.reduce( - (acc, { percent, delay }, index) => { - const delaySeconds = Math.max( - // Ensure this is at least 1 second since it can't - // have overlapping points. - 1, - convertDurationWithUnitsToSeconds(delay) - - // For the first step, subtract 1 second since - // the first point must start at 1 second and is - // hardcoded above. - (index === 0 ? 1 : 0) - ) - - // For the first step, start at 1 second since the - // first point must start at 1 second and is - // hardcoded above. - const lastSeconds = - index === 0 ? 1 : acc[acc.length - 1][0] - const lastAmount = - index === 0 ? '0' : acc[acc.length - 1][1] - - return [ - ...acc, - [ - lastSeconds + delaySeconds, - BigInt( - // For the last step, use total to avoid - // rounding issues. - index === begin.steps.length - 1 - ? total - : Math.round( - Number(lastAmount) + - (percent / 100) * Number(total) - ) - ).toString(), - ], - ] - }, - [] as [number, string][] - ) as [number, string][]), - ], - }, - start_time: - begin.startDate && !isNaN(Date.parse(begin.startDate)) - ? // milliseconds => nanoseconds - BigInt( - Math.round(new Date(begin.startDate).getTime() * 1e6) - ).toString() - : '', - title: begin.title, - total: BigInt(total).toString(), - unbonding_duration_seconds: - token.type === TokenType.Native && - token.denomOrAddress === - getNativeTokenForChainId(begin.chainId).denomOrAddress - ? nativeUnstakingDurationSeconds - : 0, - vesting_duration_seconds: vestingDurationSeconds, - } - - const msg: InstantiateNativePayrollContractMsg = { - instantiate_msg: instantiateMsg, - label: `vest_to_${begin.recipient}_${Date.now()}`, - } - - if (token.type === TokenType.Native) { - chainId = begin.chainId - cosmosMsg = makeWasmMessage({ - wasm: { - execute: { - contract_addr: vestingSource.factory, - funds: coins(total, token.denomOrAddress), - msg: { - instantiate_native_payroll_contract: msg, - } as ExecuteMsg, - }, - }, - }) - } else if (token.type === TokenType.Cw20) { - chainId = begin.chainId - // Execute CW20 send message. - cosmosMsg = makeWasmMessage({ - wasm: { - execute: { - contract_addr: token.denomOrAddress, - funds: [], - msg: { - send: { - amount: BigInt(total).toString(), - contract: vestingSource.factory, - msg: encodeJsonToBase64({ - instantiate_payroll_contract: msg, - }), - }, - }, - }, - }, - }) - } else { - throw new Error(t('error.unexpectedError')) - } - } else if (mode === 'cancel' || mode === 'registerSlash') { - if (loadingVestingInfos.loading) { - return - } else if (loadingVestingInfos.errored) { - throw loadingVestingInfos.error - } - - const contractAddress = - mode === 'cancel' ? cancel.address : registerSlash.address - const vestingInfo = loadingVestingInfos.data.find( - ({ vestingContractAddress }) => - vestingContractAddress === contractAddress - ) - if (!vestingInfo) { - throw new Error(t('error.loadingData')) - } - - const msg = makeWasmMessage({ - wasm: { - execute: { - contract_addr: contractAddress, - funds: [], - msg: - mode === 'cancel' - ? { - cancel: {}, - } - : { - register_slash: { - validator: registerSlash.validator, - time: registerSlash.time, - amount: registerSlash.amount, - during_unbonding: registerSlash.duringUnbonding, - }, - }, - }, - }, - }) - - const cancelRegisterSlashChainId = - mode === 'cancel' ? cancel.chainId : registerSlash.chainId - const from = getChainAddressForActionOptions( - options, - cancelRegisterSlashChainId - ) - - chainId = cancelRegisterSlashChainId - cosmosMsg = - vestingInfo.owner?.isCw1Whitelist && - from && - vestingInfo.owner.cw1WhitelistAdmins.includes(from) - ? // Wrap in cw1-whitelist execute. - makeWasmMessage({ - wasm: { - execute: { - contract_addr: vestingInfo.owner.address, - funds: [], - msg: { - execute: { - msgs: [msg], - }, - }, - }, - }, - }) - : msg - } else { - throw new Error(t('error.unexpectedError')) - } - - return maybeMakePolytoneExecuteMessage( - nativeChainId, - chainId, - cosmosMsg - ) - }, - [ - loadingTokenBalances, - preV1Vesting, - preV1VestingFactoryOwner, - nativeUnstakingDurationSecondsLoadable, - loadingVestingInfos, - ] - ) - } - } - - // Only check if widget exists in DAOs. - const useDefaults: UseDefaults = - context.type === ActionContextType.Dao - ? () => { - const widgetData = useWidget( - WidgetId.VestingPayments - )?.daoWidget.values - return makeUseDefaults(!!widgetData)() - } - : makeUseDefaults(false) - - // Only check if widget exists in DAOs. - const useTransformToCosmos = - context.type === ActionContextType.Dao - ? () => { - const widgetData = useWidget( - WidgetId.VestingPayments - )?.daoWidget.values - return makeUseTransformToCosmos(widgetData)() - } - : makeUseTransformToCosmos() - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - let chainId = nativeChainId - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - } - // If this is a cw1-whitelist execute msg, check msg inside of it. - const decodedCw1Whitelist = decodeCw1WhitelistExecuteMsg(msg, 'one') - if (decodedCw1Whitelist) { - msg = decodedCw1Whitelist.msgs[0] - } - - const defaults = useDefaults() as ManageVestingData - - const isNativeBegin = - objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - instantiate_native_payroll_contract: instantiateStructure, - }, - }, - }, - }) && - msg.wasm.execute.funds.length === 1 && - objectMatchesStructure(msg.wasm.execute.funds[0], { - amount: {}, - denom: {}, - }) - - const isCw20Begin = - objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - send: { - amount: {}, - contract: {}, - msg: {}, - }, - }, - }, - }, - }) && - objectMatchesStructure( - decodeJsonFromBase64(msg.wasm.execute.msg.send.msg, true), - { - instantiate_payroll_contract: instantiateStructure, - } - ) - - const isRegisterSlash = objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - register_slash: { - validator: {}, - time: {}, - amount: {}, - during_unbonding: {}, - }, - }, - }, - }, - }) - - const isCancel = objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - cancel: {}, - }, - }, - }, - }) - - const isBegin = isNativeBegin || isCw20Begin - - // Defined if the message is a begin vesting message. - const tokenLoadable = useCachedLoadable( - isBegin - ? genericTokenSelector({ - chainId, - type: isNativeBegin ? TokenType.Native : TokenType.Cw20, - denomOrAddress: isNativeBegin - ? msg.wasm.execute.funds[0].denom - : msg.wasm.execute.contract_addr, - }) - : constSelector(undefined) - ) - - let instantiateMsg: VestingInstantiateMsg | undefined - if (isBegin) { - if (isNativeBegin) { - instantiateMsg = - msg.wasm.execute.msg.instantiate_native_payroll_contract - .instantiate_msg - } - // isCw20Begin - else { - // Extract instantiate message from cw20 send message. - instantiateMsg = decodeJsonFromBase64( - msg.wasm.execute.msg.send.msg, - true - ).instantiate_payroll_contract?.instantiate_msg as VestingInstantiateMsg - } - } - - // Attempt to load cw1-whitelist admins if the owner is set. Will only - // succeed if the owner is a cw1-whitelist contract. Otherwise it returns - // undefined. - const cw1WhitelistAdminsLoadable = useCachedLoadable( - isBegin && instantiateMsg?.owner - ? Cw1WhitelistSelectors.adminsIfCw1Whitelist({ - chainId, - contractAddress: instantiateMsg.owner, - }) - : constSelector(undefined) - ) - - if ( - tokenLoadable.state !== 'hasValue' || - cw1WhitelistAdminsLoadable.state !== 'hasValue' - ) { - return { match: false } - } - - const token = tokenLoadable.contents - if (isBegin && token && instantiateMsg) { - const ownerMode = !instantiateMsg.owner - ? 'none' - : instantiateMsg.owner === - getChainAddressForActionOptions(options, chainId) - ? 'me' - : cw1WhitelistAdminsLoadable.contents - ? 'many' - : 'other' - - return { - match: true, - data: { - ...defaults, - mode: 'begin', - begin: { - chainId, - denomOrAddress: token.denomOrAddress, - description: instantiateMsg.description || undefined, - recipient: instantiateMsg.recipient, - startDate: instantiateMsg.start_time - ? new Date( - // nanoseconds => milliseconds - Number(instantiateMsg.start_time) / 1e6 - ).toISOString() - : '', - title: instantiateMsg.title, - amount: convertMicroDenomToDenomWithDecimals( - instantiateMsg.total, - token.decimals - ), - ownerMode, - otherOwner: (ownerMode === 'other' && instantiateMsg.owner) || '', - manyOwners: - ownerMode === 'many' && cw1WhitelistAdminsLoadable.contents - ? cw1WhitelistAdminsLoadable.contents.map((address) => ({ - address, - })) - : [], - manyOwnersCw1WhitelistContract: - (ownerMode === 'many' && instantiateMsg.owner) || '', - steps: - instantiateMsg.schedule === 'saturating_linear' - ? [ - { - percent: 100, - delay: convertSecondsToDurationWithUnits( - instantiateMsg.vesting_duration_seconds - ), - }, - ] - : instantiateMsg.schedule.piecewise_linear.reduce( - (acc, [seconds, amount], index) => { - // Ignore first step if hardcoded 0 amount at 1 second. - if (index === 0 && seconds === 1 && amount === '0') { - return acc - } - - const pastTimestamp = - index === 1 || - // Typecheck. Always false. - instantiateMsg!.schedule === 'saturating_linear' - ? // For first user-defined step, account for 1 second - // delay since we ignore the first hardcoded step at - // 1 second. When we created the msg, we subtracted - // 1 second from the first user-defined step's - // delay. - 0 - : instantiateMsg!.schedule.piecewise_linear[ - index - 1 - ][0] - const pastAmount = - index === 0 || - // Typecheck. Always false. - instantiateMsg!.schedule === 'saturating_linear' - ? '0' - : instantiateMsg!.schedule.piecewise_linear[ - index - 1 - ][1] - - return [ - ...acc, - { - percent: Number( - ( - ((Number(amount) - Number(pastAmount)) / - Number(instantiateMsg!.total)) * - 100 - ).toFixed(2) - ), - delay: convertSecondsToDurationWithUnits( - seconds - pastTimestamp - ), - }, - ] - }, - [] as { - percent: number - delay: DurationWithUnits - }[] - ), - }, - }, - } - } else if (isRegisterSlash) { - return { - match: true, - data: { - ...defaults, - mode: 'registerSlash', - registerSlash: { - chainId, - address: msg.wasm.execute.contract_addr, - validator: msg.wasm.execute.msg.register_slash.validator, - time: msg.wasm.execute.msg.register_slash.time, - amount: msg.wasm.execute.msg.register_slash.amount, - duringUnbonding: - msg.wasm.execute.msg.register_slash.during_unbonding, - }, - }, - } - } else if (isCancel) { - return { - match: true, - data: { - ...defaults, - mode: 'cancel', - cancel: { - chainId, - address: msg.wasm.execute.contract_addr, - }, - }, - } - } - - return { match: false } - } - - // Don't show if vesting payment widget is not enabled (for DAOs) and this - // account owns no vesting payments. - const useHideFromPicker: UseHideFromPicker = - context.type === ActionContextType.Dao - ? // For a DAO, check if the widget is enabled or if it owns any payments. - () => { - const hasWidget = useWidget(WidgetId.VestingPayments) - const ownedVestingPaymentsLoading = useVestingInfosOwnedByEntity() - const ownsVestingPayments = - !ownedVestingPaymentsLoading.loading && - !ownedVestingPaymentsLoading.errored && - !!ownedVestingPaymentsLoading.data.length - - return !hasWidget && !ownsVestingPayments - } - : // For a non-DAO, just check if address owns any payments. - () => { - const ownedVestingPaymentsLoading = useVestingInfosOwnedByEntity() - const ownsVestingPayments = - !ownedVestingPaymentsLoading.loading && - !ownedVestingPaymentsLoading.errored && - !!ownedVestingPaymentsLoading.data.length - - return !ownsVestingPayments - } - - return { - key: ActionKey.ManageVesting, - Icon: MoneyWingsEmoji, - label: t('title.manageVesting'), - description: t('info.manageVestingDescription'), - Component: - context.type === ActionContextType.Dao ? DaoComponent : WalletComponent, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - useHideFromPicker, - } -} diff --git a/packages/stateful/actions/core/treasury/index.ts b/packages/stateful/actions/core/treasury/index.ts deleted file mode 100644 index bc60fd757..000000000 --- a/packages/stateful/actions/core/treasury/index.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ActionCategoryKey, ActionCategoryMaker } from '@dao-dao/types' - -import { makeCommunityPoolDepositAction } from './CommunityPoolDeposit' -import { makeCommunityPoolSpendAction } from './CommunityPoolSpend' -import { makeConfigureVestingPaymentsAction } from './ConfigureVestingPayments' -import { makeEnableRetroactiveCompensationAction } from './EnableRetroactiveCompensation' -import { makeManageCw20Action } from './ManageCw20' -import { makeManageStakingAction } from './ManageStaking' -import { makeManageVestingAction } from './ManageVesting' -import { makeSpendAction } from './Spend' -import { - makePerformTokenSwapAction, - makeWithdrawTokenSwapAction, -} from './token_swap' - -export const makeTreasuryActionCategory: ActionCategoryMaker = ({ - t, - context, -}) => ({ - key: ActionCategoryKey.Treasury, - label: t('actionCategory.treasuryLabel', { - context: context.type, - }), - description: t('actionCategory.treasuryDescription', { - context: context.type, - }), - actionMakers: [ - makeSpendAction, - makeManageStakingAction, - makeManageVestingAction, - makeManageCw20Action, - makePerformTokenSwapAction, - makeWithdrawTokenSwapAction, - makeConfigureVestingPaymentsAction, - makeEnableRetroactiveCompensationAction, - makeCommunityPoolSpendAction, - makeCommunityPoolDepositAction, - ], -}) diff --git a/packages/stateful/actions/core/valence/CreateValenceAccount/index.tsx b/packages/stateful/actions/core/valence/CreateValenceAccount/index.tsx deleted file mode 100644 index 5460102a4..000000000 --- a/packages/stateful/actions/core/valence/CreateValenceAccount/index.tsx +++ /dev/null @@ -1,312 +0,0 @@ -import { fromUtf8, toUtf8 } from '@cosmjs/encoding' -import { useQueryClient } from '@tanstack/react-query' -import { useCallback, useEffect } from 'react' -import { useFormContext } from 'react-hook-form' - -import { valenceRebalancerExtraQueries } from '@dao-dao/state/query' -import { - AtomEmoji, - ChainProvider, - DaoSupportedChainPickerInput, -} from '@dao-dao/stateless' -import { TokenType, makeStargateMessage } from '@dao-dao/types' -import { - ActionComponent, - ActionContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { InstantiateMsg as ValenceAccountInstantiateMsg } from '@dao-dao/types/contracts/ValenceAccount' -import { MsgInstantiateContract2 } from '@dao-dao/types/protobuf/codegen/cosmwasm/wasm/v1/tx' -import { - VALENCE_INSTANTIATE2_SALT, - VALENCE_SUPPORTED_CHAINS, - convertDenomToMicroDenomStringWithDecimals, - convertMicroDenomToDenomWithDecimals, - decodeIcaExecuteMsg, - decodePolytoneExecuteMsg, - getChainAddressForActionOptions, - getDisplayNameForChainId, - getSupportedChainConfig, - isDecodedStargateMsg, - maybeMakePolytoneExecuteMessage, - mustGetSupportedChainConfig, - tokensEqual, -} from '@dao-dao/utils' - -import { useQueryLoadingDataWithError, useQueryTokens } from '../../../../hooks' -import { useTokenBalances } from '../../../hooks' -import { useActionOptions } from '../../../react/context' -import { - CreateValenceAccountComponent, - CreateValenceAccountData, -} from './Component' - -const Component: ActionComponent = (props) => { - const { context } = useActionOptions() - const { watch, setValue } = useFormContext() - - const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') - const funds = watch((props.fieldNamePrefix + 'funds') as 'funds') - - const nativeBalances = useTokenBalances({ - filter: TokenType.Native, - // Load selected tokens when not creating in case they are no longer - // returned in the list of all tokens for the given DAO/wallet after the - // proposal is made. - additionalTokens: props.isCreating - ? undefined - : funds.map(({ denom }) => ({ - chainId, - type: TokenType.Native, - denomOrAddress: denom, - })), - }) - - const queryClient = useQueryClient() - const rebalancer = mustGetSupportedChainConfig(chainId).valence?.rebalancer - const serviceFee = useQueryLoadingDataWithError( - valenceRebalancerExtraQueries.rebalancerRegistrationServiceFee( - queryClient, - rebalancer - ? { - chainId, - address: rebalancer, - } - : undefined - ) - ) - useEffect(() => { - setValue( - (props.fieldNamePrefix + 'serviceFee') as 'serviceFee', - serviceFee.loading || - serviceFee.errored || - serviceFee.updating || - !serviceFee.data - ? undefined - : { - amount: serviceFee.data.balance, - denom: serviceFee.data.token.denomOrAddress, - } - ) - }, [props.fieldNamePrefix, serviceFee, setValue]) - - return ( - <> - {context.type === ActionContextType.Dao && - VALENCE_SUPPORTED_CHAINS.length > 1 && ( - { - // Reset funds when switching chain. - setValue((props.fieldNamePrefix + 'funds') as 'funds', []) - }} - /> - )} - - - { - // Subtract service fee from balance for corresponding - // token to ensure that they leave enough for the fee. - // This value is used as the input max. - let balance = - !serviceFee.errored && - serviceFee.data && - tokensEqual(data.token, serviceFee.data.token) - ? BigInt(_balance) - BigInt(serviceFee.data.balance) - : BigInt(_balance) - if (balance < 0n) { - balance = 0n - } - - return { - ...data, - balance: balance.toString(), - } - } - ), - }, - serviceFee, - }} - /> - - - ) -} - -const useDefaults: UseDefaults = () => ({ - chainId: VALENCE_SUPPORTED_CHAINS[0], - funds: [ - { - denom: 'untrn', - amount: 10, - decimals: 6, - }, - ], -}) - -export const makeCreateValenceAccountAction: ActionMaker< - CreateValenceAccountData -> = (options) => { - const { - t, - chain: { chain_id: currentChainId }, - } = options - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - let chainId = currentChainId - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - } else { - const decodedIca = decodeIcaExecuteMsg(chainId, msg) - if (decodedIca.match) { - chainId = decodedIca.chainId - msg = decodedIca.msgWithSender?.msg || {} - } - } - - const typedMsg = - isDecodedStargateMsg(msg) && - msg.stargate.typeUrl === MsgInstantiateContract2.typeUrl && - fromUtf8(msg.stargate.value.salt) === VALENCE_INSTANTIATE2_SALT - ? (msg.stargate.value as MsgInstantiateContract2) - : undefined - - const valenceAccountCodeId = - getSupportedChainConfig(chainId)?.codeIds?.ValenceAccount - - const fundsTokens = useQueryTokens( - typedMsg?.funds.map(({ denom }) => ({ - chainId, - type: TokenType.Native, - denomOrAddress: denom, - })) - ) - - // Can't match until we have the token info. - if (fundsTokens.loading || fundsTokens.errored) { - return { match: false } - } - - return valenceAccountCodeId !== undefined && typedMsg - ? { - match: true, - data: { - chainId, - funds: typedMsg.funds.map(({ denom, amount }, index) => ({ - denom, - amount: convertMicroDenomToDenomWithDecimals( - amount, - fundsTokens.data[index].decimals - ), - decimals: fundsTokens.data[index].decimals, - })), - }, - } - : { - match: false, - } - } - - const useTransformToCosmos: UseTransformToCosmos< - CreateValenceAccountData - > = () => - useCallback(({ chainId, funds, serviceFee }) => { - const config = getSupportedChainConfig(chainId) - if (!config?.codeIds?.ValenceAccount || !config?.valence) { - throw new Error(t('error.unsupportedValenceChain')) - } - - const sender = getChainAddressForActionOptions(options, chainId) - if (!sender) { - throw new Error( - t('error.failedToFindChainAccount', { - chain: getDisplayNameForChainId(chainId), - }) - ) - } - - const convertedFunds = funds.map(({ denom, amount, decimals }) => ({ - denom, - amount: convertDenomToMicroDenomStringWithDecimals(amount, decimals), - })) - - // Add service fee to funds. - if (serviceFee && serviceFee.amount !== '0') { - const existing = convertedFunds.find( - (f) => f.denom === serviceFee.denom - ) - if (existing) { - existing.amount = ( - BigInt(existing.amount) + BigInt(serviceFee.amount) - ).toString() - } else { - convertedFunds.push({ - denom: serviceFee.denom, - amount: serviceFee.amount, - }) - } - } - - return maybeMakePolytoneExecuteMessage( - currentChainId, - chainId, - makeStargateMessage({ - stargate: { - typeUrl: MsgInstantiateContract2.typeUrl, - value: { - sender, - admin: sender, - codeId: BigInt(config.codeIds.ValenceAccount), - label: 'Valence Account', - msg: toUtf8( - JSON.stringify({ - services_manager: config.valence.servicesManager, - } as ValenceAccountInstantiateMsg) - ), - funds: convertedFunds - // Neutron errors with `invalid coins` if the funds list is - // not alphabetized. - .sort((a, b) => a.denom.localeCompare(b.denom)), - salt: toUtf8(VALENCE_INSTANTIATE2_SALT), - fixMsg: false, - } as MsgInstantiateContract2, - }, - }) - ) - }, []) - - return { - key: ActionKey.CreateValenceAccount, - Icon: AtomEmoji, - label: t('title.createValenceAccount'), - description: t('info.createValenceAccountDescription'), - notReusable: true, - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - // The configure rebalancer action is responsible for adding this action. - programmaticOnly: true, - } -} diff --git a/packages/stateful/actions/core/valence/FundRebalancer/index.tsx b/packages/stateful/actions/core/valence/FundRebalancer/index.tsx deleted file mode 100644 index 6581d5f10..000000000 --- a/packages/stateful/actions/core/valence/FundRebalancer/index.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { MoneyWingsEmoji } from '@dao-dao/stateless' -import { AccountType, ActionMaker, ChainId } from '@dao-dao/types' -import { - ActionComponent, - ActionKey, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { getAccount } from '@dao-dao/utils' - -import { StatefulSpendComponent, makeSpendAction } from '../../treasury/Spend' -import { SpendData } from '../../treasury/Spend/Component' - -export type FundRebalancerData = SpendData - -export const makeFundRebalancerAction: ActionMaker = ( - options -) => { - const { - t, - context, - address, - chain: { chain_id: chainId }, - } = options - - const spendAction = makeSpendAction(options) - if (!spendAction) { - return null - } - - const valenceAccount = getAccount({ - accounts: context.accounts, - chainId: ChainId.NeutronMainnet, - types: [AccountType.Valence], - }) - - const useDefaults: UseDefaults = () => { - const spendDefaults = spendAction.useDefaults() - - if (!valenceAccount) { - return new Error(t('error.noValenceAccount')) - } - - if (!spendDefaults) { - return - } else if (spendDefaults instanceof Error) { - return spendDefaults - } - - return { - ...spendDefaults, - fromChainId: chainId, - from: address, - toChainId: ChainId.NeutronMainnet, - to: valenceAccount.address, - } - } - - const useTransformToCosmos: UseTransformToCosmos = - spendAction.useTransformToCosmos - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - const decoded = spendAction.useDecodedCosmosMsg(msg) - - return decoded.match && - valenceAccount && - decoded.data.toChainId === valenceAccount.chainId && - decoded.data.to === valenceAccount.address - ? decoded - : { - match: false, - } - } - - const Component: ActionComponent = (props) => ( - - ) - - return { - key: ActionKey.FundRebalancer, - Icon: MoneyWingsEmoji, - label: t('title.fundRebalancer'), - description: t('info.fundRebalancerDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - // Hide if no Valence account created. - hideFromPicker: !valenceAccount, - } -} diff --git a/packages/stateful/actions/core/valence/PauseRebalancer/index.tsx b/packages/stateful/actions/core/valence/PauseRebalancer/index.tsx deleted file mode 100644 index e42086c66..000000000 --- a/packages/stateful/actions/core/valence/PauseRebalancer/index.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { useCallback } from 'react' - -import { PlayPauseEmoji } from '@dao-dao/stateless' -import { AccountType, ActionMaker, ChainId } from '@dao-dao/types' -import { - ActionKey, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { - getAccountAddress, - makeWasmMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { useActionOptions } from '../../../react' -import { - PauseRebalancerComponent as Component, - PauseRebalancerData, -} from './Component' - -const useDefaults: UseDefaults = () => { - const { t, context } = useActionOptions() - - const account = getAccountAddress({ - accounts: context.accounts, - chainId: ChainId.NeutronMainnet, - types: [AccountType.Valence], - }) - - if (!account) { - return new Error(t('error.noValenceAccount')) - } - - return { - account, - } -} - -const useTransformToCosmos: UseTransformToCosmos = () => - useCallback( - ({ account }: PauseRebalancerData) => - makeWasmMessage({ - wasm: { - execute: { - contract_addr: account, - funds: [], - msg: { - pause_service: { - service_name: 'rebalancer', - }, - }, - }, - }, - }), - [] - ) - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => - objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - pause_service: { - service_name: {}, - }, - }, - }, - }, - }) && msg.wasm.execute.msg.pause_service.service_name === 'rebalancer' - ? { - match: true, - data: { - account: msg.wasm.execute.contract_addr, - }, - } - : { - match: false, - } - -export const makePauseRebalancerAction: ActionMaker = ({ - t, - context, -}) => ({ - key: ActionKey.PauseRebalancer, - Icon: PlayPauseEmoji, - label: t('title.pauseRebalancer'), - description: t('info.pauseRebalancerDescription'), - notReusable: true, - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - // Hide if no Valence account created. - hideFromPicker: !context.accounts.some( - ({ type }) => type === AccountType.Valence - ), -}) diff --git a/packages/stateful/actions/core/valence/ResumeRebalancer/index.tsx b/packages/stateful/actions/core/valence/ResumeRebalancer/index.tsx deleted file mode 100644 index aaab31d48..000000000 --- a/packages/stateful/actions/core/valence/ResumeRebalancer/index.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { useCallback } from 'react' - -import { PlayPauseEmoji } from '@dao-dao/stateless' -import { AccountType, ActionMaker, ChainId } from '@dao-dao/types' -import { - ActionKey, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { - getAccountAddress, - makeWasmMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { useActionOptions } from '../../../react' -import { - ResumeRebalancerComponent as Component, - ResumeRebalancerData, -} from './Component' - -const useDefaults: UseDefaults = () => { - const { t, context } = useActionOptions() - - const account = getAccountAddress({ - accounts: context.accounts, - chainId: ChainId.NeutronMainnet, - types: [AccountType.Valence], - }) - - if (!account) { - return new Error(t('error.noValenceAccount')) - } - - return { - account, - } -} - -const useTransformToCosmos: UseTransformToCosmos = () => - useCallback( - ({ account }: ResumeRebalancerData) => - makeWasmMessage({ - wasm: { - execute: { - contract_addr: account, - funds: [], - msg: { - resume_service: { - service_name: 'rebalancer', - }, - }, - }, - }, - }), - [] - ) - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => - objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - resume_service: { - service_name: {}, - }, - }, - }, - }, - }) && msg.wasm.execute.msg.resume_service.service_name === 'rebalancer' - ? { - match: true, - data: { - account: msg.wasm.execute.contract_addr, - }, - } - : { - match: false, - } - -export const makeResumeRebalancerAction: ActionMaker = ({ - t, - context, -}) => ({ - key: ActionKey.ResumeRebalancer, - Icon: PlayPauseEmoji, - label: t('title.resumeRebalancer'), - description: t('info.resumeRebalancerDescription'), - notReusable: true, - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - // Hide if no Valence account created. - hideFromPicker: !context.accounts.some( - ({ type }) => type === AccountType.Valence - ), -}) diff --git a/packages/stateful/actions/core/valence/WithdrawFromRebalancer/index.tsx b/packages/stateful/actions/core/valence/WithdrawFromRebalancer/index.tsx deleted file mode 100644 index 287ee8bf3..000000000 --- a/packages/stateful/actions/core/valence/WithdrawFromRebalancer/index.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { useCallback } from 'react' - -import { DownArrowEmoji } from '@dao-dao/stateless' -import { AccountType, ActionMaker, ChainId } from '@dao-dao/types' -import { - ActionComponent, - ActionKey, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { - decodeIcaExecuteMsg, - decodePolytoneExecuteMsg, - getAccount, - makeWasmMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { StatefulSpendComponent, makeSpendAction } from '../../treasury/Spend' -import { SpendData } from '../../treasury/Spend/Component' - -export type WithdrawFromRebalancerData = SpendData - -export const makeWithdrawFromRebalancerAction: ActionMaker< - WithdrawFromRebalancerData -> = (options) => { - const { - t, - context, - address, - chain: { chain_id: currentChainId }, - } = options - - const spendAction = makeSpendAction({ - ...options, - fromValence: true, - }) - if (!spendAction) { - return null - } - - const valenceAccount = getAccount({ - accounts: context.accounts, - chainId: ChainId.NeutronMainnet, - types: [AccountType.Valence], - }) - - const useDefaults: UseDefaults = () => { - const spendDefaults = spendAction.useDefaults() - - if (!valenceAccount) { - return new Error(t('error.noValenceAccount')) - } - - if (!spendDefaults) { - return - } else if (spendDefaults instanceof Error) { - return spendDefaults - } - - return { - ...spendDefaults, - fromChainId: ChainId.NeutronMainnet, - from: valenceAccount.address, - toChainId: currentChainId, - to: address, - } - } - - const useTransformToCosmos: UseTransformToCosmos< - WithdrawFromRebalancerData - > = () => { - const transform = spendAction.useTransformToCosmos() - - return useCallback( - (data) => { - if (!valenceAccount) { - throw new Error(t('error.noValenceAccount')) - } - - const transformed = transform(data) - if (!transformed) { - return - } - - return makeWasmMessage({ - wasm: { - execute: { - contract_addr: valenceAccount.address, - funds: [], - msg: { - execute_by_admin: { - msgs: [transformed], - }, - }, - }, - }, - }) - }, - [transform] - ) - } - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - let chainId = currentChainId - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - msg = decodedPolytone.msg - chainId = decodedPolytone.chainId - } else { - const decodedIca = decodeIcaExecuteMsg(chainId, msg) - if (decodedIca.match) { - chainId = decodedIca.chainId - msg = decodedIca.msgWithSender?.msg || {} - } - } - - const isExecuteByAdmin = - objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - execute_by_admin: { - msgs: {}, - }, - }, - }, - }, - }) && - msg.wasm.execute.contract_addr === valenceAccount?.address && - Array.isArray(msg.wasm.execute.msg.execute_by_admin.msgs) && - msg.wasm.execute.msg.execute_by_admin.msgs.length === 1 - - // Only attempt to decode execute by admin msg. - const decoded = spendAction.useDecodedCosmosMsg( - isExecuteByAdmin ? msg.wasm.execute.msg.execute_by_admin.msgs[0] : {} - ) - - return isExecuteByAdmin && - decoded.match && - valenceAccount && - decoded.data.toChainId === currentChainId && - decoded.data.to === address - ? decoded - : { - match: false, - } - } - - const Component: ActionComponent = (props) => ( - - ) - - return { - key: ActionKey.WithdrawFromRebalancer, - Icon: DownArrowEmoji, - label: t('title.withdrawFromRebalancer'), - description: t('info.withdrawFromRebalancerDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - // Hide if no Valence account created. - hideFromPicker: !valenceAccount, - } -} diff --git a/packages/stateful/actions/core/valence/index.ts b/packages/stateful/actions/core/valence/index.ts deleted file mode 100644 index bdae74b9e..000000000 --- a/packages/stateful/actions/core/valence/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ActionCategoryKey, ActionCategoryMaker } from '@dao-dao/types' -import { actionContextSupportsValence } from '@dao-dao/utils' - -import { makeConfigureRebalancerAction } from './ConfigureRebalancer' -import { makeCreateValenceAccountAction } from './CreateValenceAccount' -import { makeFundRebalancerAction } from './FundRebalancer' -import { makePauseRebalancerAction } from './PauseRebalancer' -import { makeResumeRebalancerAction } from './ResumeRebalancer' -import { makeWithdrawFromRebalancerAction } from './WithdrawFromRebalancer' - -export const makeValenceActionCategory: ActionCategoryMaker = (options) => - actionContextSupportsValence(options) - ? { - key: ActionCategoryKey.Rebalancer, - label: options.t('actionCategory.rebalancerLabel', { - context: options.context.type, - }), - description: options.t('actionCategory.rebalancerDescription', { - context: options.context.type, - }), - actionMakers: [ - makeConfigureRebalancerAction, - makeFundRebalancerAction, - makeWithdrawFromRebalancerAction, - makePauseRebalancerAction, - makeResumeRebalancerAction, - makeCreateValenceAccountAction, - ], - } - : null diff --git a/packages/stateful/actions/hooks/useMsgExecutesContract.ts b/packages/stateful/actions/hooks/useMsgExecutesContract.ts index 31976d192..d36bf77a5 100644 --- a/packages/stateful/actions/hooks/useMsgExecutesContract.ts +++ b/packages/stateful/actions/hooks/useMsgExecutesContract.ts @@ -1,11 +1,9 @@ import { constSelector } from 'recoil' import { isContractSelector } from '@dao-dao/state/recoil' -import { useCachedLoadable } from '@dao-dao/stateless' +import { useActionOptions, useCachedLoadable } from '@dao-dao/stateless' import { Structure, objectMatchesStructure } from '@dao-dao/utils' -import { useActionOptions } from '../react/context' - // Returns if the message is a wasm message that executes a specific contract. // The names are checked against the data stored in contract_info, via the // indexer's `info` formula, falling back to a contract `info` query. diff --git a/packages/stateful/actions/hooks/useTokenBalances.ts b/packages/stateful/actions/hooks/useTokenBalances.ts index 58b5b0339..155872993 100644 --- a/packages/stateful/actions/hooks/useTokenBalances.ts +++ b/packages/stateful/actions/hooks/useTokenBalances.ts @@ -2,7 +2,7 @@ import { allBalancesSelector, communityPoolBalancesSelector, } from '@dao-dao/state' -import { useCachedLoading } from '@dao-dao/stateless' +import { useActionOptions, useCachedLoading } from '@dao-dao/stateless' import { AccountType, ActionContextType, @@ -13,7 +13,6 @@ import { } from '@dao-dao/types' import { useCw20CommonGovernanceTokenInfoIfExists } from '../../voting-module-adapter' -import { useActionOptions } from '../react' export type UseTokenBalancesOptions = { /** diff --git a/packages/stateful/actions/index.ts b/packages/stateful/actions/index.ts index 4c303e955..989321ecc 100644 --- a/packages/stateful/actions/index.ts +++ b/packages/stateful/actions/index.ts @@ -1,3 +1,4 @@ +export * from './context' export * from './core' export * from './hooks' -export * from './react' +export * from './providers' diff --git a/packages/stateful/actions/providers/base.tsx b/packages/stateful/actions/providers/base.tsx new file mode 100644 index 000000000..c78701ba4 --- /dev/null +++ b/packages/stateful/actions/providers/base.tsx @@ -0,0 +1,93 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +import { processMessage } from '@dao-dao/state' +import { ActionsContext, useChainContext } from '@dao-dao/stateless' +import { + ActionChainContext, + ActionChainContextType, + ActionContext, + ActionMap, + ActionOptions, + ActionsProviderProps, + IActionsContext, +} from '@dao-dao/types' + +import { + getCoreActionCategoryMakers, + getCoreActions, + makeActionCategories, +} from '../core' + +export const BaseActionsProvider = ({ + address, + actionContext, + children, +}: ActionsProviderProps & { + address: string + actionContext: ActionContext +}) => { + const { t } = useTranslation() + const chainContext = useChainContext() + const queryClient = useQueryClient() + + const context: IActionsContext = useMemo(() => { + const actionChainContext: ActionChainContext = chainContext.config + ? { + type: ActionChainContextType.Supported, + ...chainContext, + // Type-check. + config: chainContext.config, + } + : chainContext.base + ? { + type: ActionChainContextType.Configured, + ...chainContext, + config: chainContext.base, + } + : { + type: ActionChainContextType.Any, + ...chainContext, + } + + const options: ActionOptions = { + t, + chain: chainContext.chain, + chainContext: actionChainContext, + address, + context: actionContext, + queryClient, + } + + const coreActions = getCoreActions() + const coreActionCategoryMakers = getCoreActionCategoryMakers() + + const actions = coreActions.flatMap((Action) => { + // Action constructor throws error for invalid contexts. + try { + return new Action(options) + } catch { + return [] + } + }) + const categories = makeActionCategories(coreActionCategoryMakers, options) + + return { + options, + actions, + actionMap: actions.reduce((map, action) => { + map[action.key] = action + return map + }, {} as ActionMap), + categories, + messageProcessor: processMessage, + } + }, [chainContext, t, address, actionContext, queryClient]) + + return ( + + {children} + + ) +} diff --git a/packages/stateful/actions/providers/dao.tsx b/packages/stateful/actions/providers/dao.tsx new file mode 100644 index 000000000..9b1c4dd36 --- /dev/null +++ b/packages/stateful/actions/providers/dao.tsx @@ -0,0 +1,133 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +import { processMessage } from '@dao-dao/state' +import { + ActionsContext, + useDaoContext, + useSupportedChainContext, +} from '@dao-dao/stateless' +import { + Action, + ActionCategoryMaker, + ActionChainContextType, + ActionContextType, + ActionMap, + ActionOptions, + ActionsProviderProps, + IActionsContext, +} from '@dao-dao/types' + +import { useVotingModuleAdapter } from '../../voting-module-adapter' +import { useWidgets } from '../../widgets' +import { + getCoreActionCategoryMakers, + getCoreActions, + makeActionCategories, +} from '../core' + +// Make sure this re-renders when the options change. You can do this by setting +// a `key` on this component or one of its ancestors. See DaoPageWrapper.tsx +// where this component is used for a usage example. +export const DaoActionsProvider = ({ children }: ActionsProviderProps) => { + const { t } = useTranslation() + const chainContext = useSupportedChainContext() + const { dao } = useDaoContext() + const queryClient = useQueryClient() + + // Get the action category makers for a DAO from its various sources: + // - core actions + // - voting module adapter actions + // - all proposal module adapters actions + // - widget adapter actions + // + // The core action categories are relevant to all DAOs, and the adapter action + // categories are relevant to the DAO's specific modules. There will be one + // voting module and many proposal modules. + + // Get voting module adapter actions. + const votingModuleActions = useVotingModuleAdapter().fields.actions + + // Get widgets to load actions from. + const loadingWidgets = useWidgets() + const loadedWidgets = loadingWidgets.loading ? undefined : loadingWidgets.data + + // Combine all actions and categories. Memoize this all so we don't + // reconstruct the actions and categories on every render. If the maker + // function is called on every render, components get redefined and flicker, + // causing them to not be editable and constantly re-render. + const context: IActionsContext = useMemo(() => { + const options: ActionOptions = { + t, + chain: chainContext.chain, + chainContext: { + type: ActionChainContextType.Supported, + ...chainContext, + }, + address: dao.coreAddress, + context: { + type: ActionContextType.Dao, + dao, + accounts: dao.info.accounts, + }, + queryClient, + } + + const coreActions = getCoreActions() + const coreActionCategoryMakers = getCoreActionCategoryMakers() + + // Get all actions for all widgets. + const widgetActions = + loadedWidgets?.flatMap( + ({ widget, daoWidget }) => + widget.getActions?.(daoWidget.values || {}) || [] + ) ?? [] + + // Combine all actions. + const actions: Action[] = [ + ...[ + ...coreActions, + ...(votingModuleActions?.actions || []), + ...widgetActions.flatMap(({ actions }) => actions || []), + ].flatMap((Action) => { + // Action constructor throws error for invalid contexts. + try { + return new Action(options) + } catch { + return [] + } + }), + ...widgetActions.flatMap( + ({ actionMakers }) => + actionMakers?.flatMap((maker) => maker(options) || []) || [] + ), + ] + + const categoryMakers: ActionCategoryMaker[] = [ + ...coreActionCategoryMakers, + ...(votingModuleActions?.categoryMakers || []), + ...widgetActions.flatMap(({ categoryMakers }) => categoryMakers), + ] + + // Make action categories. + const categories = makeActionCategories(categoryMakers, options) + + return { + options, + actions, + actionMap: actions.reduce((map, action) => { + map[action.key] = action + return map + }, {} as ActionMap), + categories, + messageProcessor: processMessage, + } + }, [chainContext, dao, loadedWidgets, queryClient, t, votingModuleActions]) + + return ( + + {children} + + ) +} diff --git a/packages/stateful/actions/providers/gov.tsx b/packages/stateful/actions/providers/gov.tsx new file mode 100644 index 000000000..e60799db1 --- /dev/null +++ b/packages/stateful/actions/providers/gov.tsx @@ -0,0 +1,65 @@ +import { useQueryClient } from '@tanstack/react-query' + +import { accountQueries, chainQueries } from '@dao-dao/state' +import { ErrorPage, PageLoader, useChain } from '@dao-dao/stateless' +import { + ActionContextType, + ChainId, + GovActionsProviderProps, +} from '@dao-dao/types' + +import { useQueryLoadingDataWithError } from '../../hooks' +import { BaseActionsProvider } from './base' + +export const GovActionsProvider = ({ + loader, + children, +}: GovActionsProviderProps) => { + const { chain_id: chainId } = useChain() + const queryClient = useQueryClient() + const govParams = useQueryLoadingDataWithError( + chainQueries.govParams(queryClient, { + chainId, + }) + ) + const moduleAddress = useQueryLoadingDataWithError( + chainQueries.moduleAddress({ + chainId, + name: 'gov', + }) + ) + const accounts = useQueryLoadingDataWithError( + moduleAddress.loading || moduleAddress.errored + ? undefined + : accountQueries.list(queryClient, { + chainId, + address: moduleAddress.data, + // Make sure to load ICAs for Neutron so Valence accounts load. + includeIcaChains: [ChainId.NeutronMainnet], + }) + ) + + return govParams.loading || + moduleAddress.loading || + (accounts.loading && !moduleAddress.errored) ? ( + <>{loader || } + ) : govParams.errored ? ( + + ) : moduleAddress.errored ? ( + + ) : accounts.errored ? ( + + ) : ( + + {children} + + ) +} diff --git a/packages/stateful/actions/providers/index.ts b/packages/stateful/actions/providers/index.ts new file mode 100644 index 000000000..d49e9f2d2 --- /dev/null +++ b/packages/stateful/actions/providers/index.ts @@ -0,0 +1,3 @@ +export * from './dao' +export * from './gov' +export * from './wallet' diff --git a/packages/stateful/actions/providers/wallet.tsx b/packages/stateful/actions/providers/wallet.tsx new file mode 100644 index 000000000..376781ce1 --- /dev/null +++ b/packages/stateful/actions/providers/wallet.tsx @@ -0,0 +1,51 @@ +import { useQueryClient } from '@tanstack/react-query' + +import { accountQueries } from '@dao-dao/state/query' +import { ErrorPage, Loader } from '@dao-dao/stateless' +import { ActionContextType, WalletActionsProviderProps } from '@dao-dao/types' + +import { + useProfile, + useQueryLoadingDataWithError, + useWallet, +} from '../../hooks' +import { BaseActionsProvider } from './base' + +export const WalletActionsProvider = ({ + address: overrideAddress, + children, +}: WalletActionsProviderProps) => { + const { address: connectedAddress, chain } = useWallet() + + const address = + overrideAddress === undefined ? connectedAddress : overrideAddress + + const { profile } = useProfile({ address }) + + const queryClient = useQueryClient() + const accounts = useQueryLoadingDataWithError( + address + ? accountQueries.list(queryClient, { + chainId: chain.chain_id, + address, + }) + : undefined + ) + + return address === undefined || profile.loading || accounts.loading ? ( + + ) : accounts.errored ? ( + + ) : ( + + {children} + + ) +} diff --git a/packages/stateful/actions/react/context.ts b/packages/stateful/actions/react/context.ts deleted file mode 100644 index 01fe9f5fb..000000000 --- a/packages/stateful/actions/react/context.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { createContext, useContext, useMemo } from 'react' -import { useDeepCompareMemoize } from 'use-deep-compare-effect' - -import { - ActionCategoryWithLabel, - ActionKey, - IActionsContext, - LoadedActions, - UseActionsOptions, -} from '@dao-dao/types/actions' - -import { actionKeyToMatchOrder } from './utils' - -//! External - -export const ActionsContext = createContext(null) - -const useActionsContext = (): IActionsContext => { - const context = useContext(ActionsContext) - - if (!context) { - throw new Error( - 'useActionsContext can only be used in a descendant of ActionsProviderProps.' - ) - } - - return context -} - -export const useActionCategories = ({ - isCreating = true, -}: UseActionsOptions = {}): ActionCategoryWithLabel[] => { - const categories = useActionsContext().categories.map((category) => ({ - ...category, - actions: category.actions.map((action) => ({ - ...action, - // Add hook to `hideFromPicker` property. - hideFromPicker: !!action.useHideFromPicker?.() || action.hideFromPicker, - })), - })) - - return useMemo( - () => - categories - .map((c) => - // Filter out actions which are not allowed to be manually chosen. - !isCreating - ? c - : { - ...c, - actions: c.actions.filter( - (action) => !action.hideFromPicker && !action.programmaticOnly - ), - } - ) - // Filter out categories with no actions. - .filter((c) => c.actions.length > 0), - // eslint-disable-next-line react-hooks/exhaustive-deps - useDeepCompareMemoize([categories, isCreating]) - ) -} - -// Get flattened list of actions from categories ordered for matching messages -// to actions. -export const useActionsForMatching = () => { - const categories = useActionCategories({ isCreating: false }) - - return useMemo( - () => - categories - .flatMap((category) => category.actions) - .sort((a, b) => { - const aValue = actionKeyToMatchOrder(a.key) - const bValue = actionKeyToMatchOrder(b.key) - return aValue - bValue - }), - [categories] - ) -} - -// Access options passed to actions. -export const useActionOptions = () => useActionsContext().options - -// Only core actions are always provided. Adapter-specific actions may be -// available but are not guaranteed based on the context. -export const useActionForKey = (actionKey: ActionKey) => - useActionsForMatching().find(({ key }) => key === actionKey) - -// Flatten action categories into processed list of actions for generating -// messages from actions. -export const useLoadedActionsAndCategories = ( - ...args: Parameters -): { - loadedActions: LoadedActions - categories: ActionCategoryWithLabel[] -} => { - const categories = useActionCategories(...args) - - // Load actions by calling hooks necessary to using the action. This calls the - // hooks in the same order every time, as action categories do not change, so - // this is a safe use of hooks. Get all action categories, even those hidden - // from the picker, since we still want to be able to render them if they're - // added programatically. - const loadedActions = useActionCategories({ isCreating: false }).reduce( - (acc, category) => { - category.actions.forEach((action) => { - acc[action.key] = { - category, - action, - transform: action.useTransformToCosmos(), - defaults: action.useDefaults(), - } - }) - return acc - }, - {} as LoadedActions - ) - - return { - loadedActions, - categories, - } -} diff --git a/packages/stateful/actions/react/index.tsx b/packages/stateful/actions/react/index.tsx deleted file mode 100644 index 709eb6cce..000000000 --- a/packages/stateful/actions/react/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export * from './context' -export * from './provider' diff --git a/packages/stateful/actions/react/provider.tsx b/packages/stateful/actions/react/provider.tsx deleted file mode 100644 index fc9b68e80..000000000 --- a/packages/stateful/actions/react/provider.tsx +++ /dev/null @@ -1,276 +0,0 @@ -import { useQueryClient } from '@tanstack/react-query' -import { useMemo } from 'react' -import { useTranslation } from 'react-i18next' -import { waitForAll } from 'recoil' - -import { accountQueries } from '@dao-dao/state/query' -import { - accountsSelector, - govParamsSelector, - moduleAddressSelector, -} from '@dao-dao/state/recoil' -import { - ErrorPage, - Loader, - PageLoader, - useCachedLoadingWithError, - useChain, - useChainContext, - useDaoContext, - useSupportedChainContext, -} from '@dao-dao/stateless' -import { - ActionChainContext, - ActionChainContextType, - ActionContext, - ActionContextType, - ActionOptions, - ActionsProviderProps, - ChainId, - GovActionsProviderProps, - IActionsContext, - WalletActionsProviderProps, -} from '@dao-dao/types' - -import { useProfile, useQueryLoadingDataWithError } from '../../hooks' -import { useWallet } from '../../hooks/useWallet' -import { matchAndLoadCommon } from '../../proposal-module-adapter' -import { useVotingModuleAdapter } from '../../voting-module-adapter' -import { useWidgets } from '../../widgets' -import { - getCoreActionCategoryMakers, - makeActionCategoriesWithLabel, -} from '../core' -import { ActionsContext } from './context' - -// Make sure this re-renders when the options change. You can do this by setting -// a `key` on this component or one of its ancestors. See DaoPageWrapper.tsx -// where this component is used for a usage example. -export const DaoActionsProvider = ({ children }: ActionsProviderProps) => { - const { t } = useTranslation() - const chainContext = useSupportedChainContext() - const { dao } = useDaoContext() - - const options: ActionOptions = { - t, - chain: chainContext.chain, - chainContext: { - type: ActionChainContextType.Supported, - ...chainContext, - }, - address: dao.coreAddress, - context: { - type: ActionContextType.Dao, - dao, - accounts: dao.info.accounts, - }, - } - - // Get the action category makers for a DAO from its various sources: - // - core actions - // - voting module adapter actions - // - all proposal module adapters actions - // - widget adapter actions - // - // The core action categories are relevant to all DAOs, and the adapter action - // categories are relevant to the DAO's specific modules. There will be one - // voting module and many proposal modules. - - const coreActionCategoryMakers = getCoreActionCategoryMakers() - - // Get voting module adapter actions. - const votingModuleActionCategoryMakers = - useVotingModuleAdapter().fields.actionCategoryMakers - - // Get all actions for all proposal module adapters. - const proposalModuleActionCategoryMakers = useMemo( - () => - dao.info.proposalModules.flatMap( - (proposalModule) => - matchAndLoadCommon(dao, proposalModule.address).fields - .actionCategoryMakers || [] - ), - [dao] - ) - - const loadingWidgets = useWidgets() - const loadedWidgets = loadingWidgets.loading ? undefined : loadingWidgets.data - // Memoize this so we don't reconstruct the action makers on every render. The - // React components often need to access data from the widget values object so - // they are defined in the maker functions. If the maker function is called on - // every render, the components will get redefined and will flicker and not be - // editable because they're constantly re-rendering. - const widgetActionCategoryMakers = useMemo( - () => - loadedWidgets?.flatMap( - ({ widget, daoWidget }) => - widget.getActionCategoryMakers?.(daoWidget.values || {}) ?? [] - ) ?? [], - [loadedWidgets] - ) - - // Make action categories. - const categories = makeActionCategoriesWithLabel( - [ - ...coreActionCategoryMakers, - ...votingModuleActionCategoryMakers, - ...proposalModuleActionCategoryMakers, - ...widgetActionCategoryMakers, - ], - options - ) - - const context: IActionsContext = { - options, - categories, - } - - return ( - - {children} - - ) -} - -export const BaseActionsProvider = ({ - address, - context, - children, -}: ActionsProviderProps & { - address: string - context: ActionContext -}) => { - const { t } = useTranslation() - - const chainContext = useChainContext() - const actionChainContext: ActionChainContext = chainContext.config - ? { - type: ActionChainContextType.Supported, - ...chainContext, - // Type-check. - config: chainContext.config, - } - : chainContext.base - ? { - type: ActionChainContextType.Configured, - ...chainContext, - config: chainContext.base, - } - : { - type: ActionChainContextType.Any, - ...chainContext, - } - - const options: ActionOptions = { - t, - chain: chainContext.chain, - chainContext: actionChainContext, - address, - context, - } - - const categories = makeActionCategoriesWithLabel( - getCoreActionCategoryMakers(), - options - ) - - return ( - - {children} - - ) -} - -export const WalletActionsProvider = ({ - address: overrideAddress, - children, -}: WalletActionsProviderProps) => { - const { address: connectedAddress, chain } = useWallet() - - const address = - overrideAddress === undefined ? connectedAddress : overrideAddress - - const { profile } = useProfile({ address }) - - const queryClient = useQueryClient() - const accounts = useQueryLoadingDataWithError( - address - ? accountQueries.list(queryClient, { - chainId: chain.chain_id, - address, - }) - : undefined - ) - - return address === undefined || profile.loading || accounts.loading ? ( - - ) : accounts.errored ? ( - - ) : ( - - {children} - - ) -} - -export const GovActionsProvider = ({ - loader, - children, -}: GovActionsProviderProps) => { - const { chain_id: chainId } = useChain() - const govDataLoading = useCachedLoadingWithError( - waitForAll([ - moduleAddressSelector({ - name: 'gov', - chainId, - }), - govParamsSelector({ - chainId, - }), - ]) - ) - - const accounts = useCachedLoadingWithError( - govDataLoading.loading || govDataLoading.errored - ? undefined - : accountsSelector({ - chainId, - address: govDataLoading.data[0], - // Make sure to load ICAs for Neutron so Valence accounts load. - includeIcaChains: [ChainId.NeutronMainnet], - }) - ) - - return govDataLoading.loading || - (accounts.loading && !govDataLoading.errored) ? ( - <>{loader || } - ) : govDataLoading.errored ? ( - - ) : accounts.errored ? ( - - ) : ( - - {children} - - ) -} diff --git a/packages/stateful/actions/react/utils.ts b/packages/stateful/actions/react/utils.ts deleted file mode 100644 index 314938b48..000000000 --- a/packages/stateful/actions/react/utils.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ActionKey } from '@dao-dao/types' - -// This returns the order value of an action for matching. It ensures the last -// actions those in the array below, since these are all catch-alls for other -// actions, custom being the broadest catch-all for all messages. Do this by -// assigning values and sorting the actions in ascending order. See -// `./provider.tsx` for usage. -export const actionKeyToMatchOrder = (key: ActionKey) => - ( - [ - // Some actions, like create/delete Press post, are more specific NFT - // operations. - ActionKey.MintNft, - ActionKey.BurnNft, - // This is before manage storage items because it is a more specific item - // management action. - ActionKey.ManageWidgets, - // This is before manage storage items because it uses item storage when - // the cw721 contract is improperly formatted. - ActionKey.ManageCw721, - // Many actions are more specific item management actions. - ActionKey.ManageStorageItems, - // Some actions are instantiate actions. - ActionKey.Instantiate, - ActionKey.Instantiate2, - // The upgrade action (and likely future upgrade actions) are a specific - // migrate action, so this needs to be after all those. - ActionKey.Migrate, - // This is a more specific execute action, so it must be before execute. - ActionKey.CrossChainExecute, - // Most messages are some form of execute. Migrate and execute are - // different, so the order between these two is irrelevant. - ActionKey.Execute, - // This is last because it catches all messages. - ActionKey.Custom, - ] as ActionKey[] - ).indexOf(key) diff --git a/packages/stateful/clients/dao/CreatingDaoPlaceholder.ts b/packages/stateful/clients/dao/CreatingDaoPlaceholder.ts index 31e17c3ff..52222ba93 100644 --- a/packages/stateful/clients/dao/CreatingDaoPlaceholder.ts +++ b/packages/stateful/clients/dao/CreatingDaoPlaceholder.ts @@ -63,7 +63,7 @@ export class CreatingDaoPlaceholder extends DaoBase { polytoneProxies: {}, accounts: [ { - type: AccountType.Native, + type: AccountType.Base, chainId: options.chainId, address: options.coreAddress, }, diff --git a/packages/stateful/clients/dao/CwDao.ts b/packages/stateful/clients/dao/CwDao.ts index c2ff3c3f5..7379c2e9a 100644 --- a/packages/stateful/clients/dao/CwDao.ts +++ b/packages/stateful/clients/dao/CwDao.ts @@ -44,7 +44,7 @@ import { } from '../voting-module' import { DaoBase } from './base' -const getVotingModuleBases = () => [ +export const getVotingModuleBases = () => [ Cw4VotingModule, Cw20StakedVotingModule, Cw721StakedVotingModule, @@ -55,7 +55,7 @@ const getVotingModuleBases = () => [ SgCommunityNftVotingModule, ] -const getProposalModuleBases = () => [ +export const getProposalModuleBases = () => [ SingleChoiceProposalModule, MultipleChoiceProposalModule, ] diff --git a/packages/stateful/clients/dao/SecretCwDao.ts b/packages/stateful/clients/dao/SecretCwDao.ts index fda9ded50..6a700944b 100644 --- a/packages/stateful/clients/dao/SecretCwDao.ts +++ b/packages/stateful/clients/dao/SecretCwDao.ts @@ -37,14 +37,14 @@ import { } from '../voting-module' import { CwDao } from './CwDao' -const getVotingModuleBases = () => [ +export const getVotingModuleBases = () => [ SecretCw4VotingModule, // SecretSnip20StakedVotingModule, SecretSnip721StakedVotingModule, SecretTokenStakedVotingModule, ] -const getProposalModuleBases = () => [ +export const getProposalModuleBases = () => [ SecretSingleChoiceProposalModule, SecretMultipleChoiceProposalModule, ] diff --git a/packages/stateful/clients/dao/index.ts b/packages/stateful/clients/dao/index.ts index 92df01501..e5bc4b256 100644 --- a/packages/stateful/clients/dao/index.ts +++ b/packages/stateful/clients/dao/index.ts @@ -1,6 +1,6 @@ export * from './ChainXGovDao' export * from './CreatingDaoPlaceholder' -export * from './CwDao' -export * from './SecretCwDao' +export { CwDao } from './CwDao' +export { SecretCwDao } from './SecretCwDao' export * from './getDao' diff --git a/packages/stateful/clients/proposal-module/MultipleChoiceProposalModule.secret.ts b/packages/stateful/clients/proposal-module/MultipleChoiceProposalModule.secret.ts index b4d17f69e..d9782d995 100644 --- a/packages/stateful/clients/proposal-module/MultipleChoiceProposalModule.secret.ts +++ b/packages/stateful/clients/proposal-module/MultipleChoiceProposalModule.secret.ts @@ -1,21 +1,30 @@ -import { FetchQueryOptions } from '@tanstack/react-query' +import { FetchQueryOptions, QueryClient } from '@tanstack/react-query' import { SecretDaoPreProposeMultipleClient, SecretDaoProposalMultipleClient, } from '@dao-dao/state/contracts' -import { secretDaoProposalMultipleQueries } from '@dao-dao/state/query' -import { Coin, SecretModuleInstantiateInfo } from '@dao-dao/types' +import { + secretDaoPreProposeMultipleQueries, + secretDaoProposalMultipleQueries, +} from '@dao-dao/state/query' +import { + CheckedDepositInfo, + Coin, + Duration, + SecretModuleInstantiateInfo, +} from '@dao-dao/types' import { InstantiateMsg as SecretDaoPreProposeMultipleInstantiateMsg, UncheckedDepositInfo, } from '@dao-dao/types/contracts/SecretDaoPreProposeMultiple' import { - Duration, + Config, InstantiateMsg, MultipleChoiceVote, PercentageThreshold, PreProposeInfo, + ProposalResponse, VetoConfig, VoteInfo, VoteResponse, @@ -35,9 +44,11 @@ import { ProposalModuleBase } from './base' export class SecretMultipleChoiceProposalModule extends ProposalModuleBase< SecretCwDao, NewProposalData, + ProposalResponse, VoteResponse, VoteInfo, - MultipleChoiceVote + MultipleChoiceVote, + Config > { static contractNames: readonly string[] = DAO_PROPOSAL_MULTIPLE_CONTRACT_NAMES @@ -113,6 +124,19 @@ export class SecretMultipleChoiceProposalModule extends ProposalModuleBase< } } + /** + * Query options to fetch the DAO address. + */ + static getDaoAddressQuery( + _: QueryClient, + options: { + chainId: string + contractAddress: string + } + ) { + return secretDaoProposalMultipleQueries.dao(options) + } + async propose({ data, getSigningClient, @@ -278,6 +302,28 @@ export class SecretMultipleChoiceProposalModule extends ProposalModuleBase< }) } + getProposalQuery({ + proposalId, + }: { + proposalId: number + }): FetchQueryOptions { + return secretDaoProposalMultipleQueries.proposal({ + chainId: this.dao.chainId, + contractAddress: this.address, + args: { + proposalId, + }, + }) + } + + async getProposal( + ...params: Parameters< + SecretMultipleChoiceProposalModule['getProposalQuery'] + > + ): Promise { + return await this.queryClient.fetchQuery(this.getProposalQuery(...params)) + } + getVoteQuery({ proposalId, voter, @@ -289,7 +335,7 @@ export class SecretMultipleChoiceProposalModule extends ProposalModuleBase< const permit = voter && this.dao.getExistingPermit(voter) return secretDaoProposalMultipleQueries.getVote({ chainId: this.dao.chainId, - contractAddress: this.info.address, + contractAddress: this.address, // Force type-cast since the query won't be enabled until this is set. // This allows us to pass an undefined `voter` argument in order to // invalidate/refresh the query for all voters. @@ -328,7 +374,68 @@ export class SecretMultipleChoiceProposalModule extends ProposalModuleBase< getProposalCountQuery(): FetchQueryOptions { return secretDaoProposalMultipleQueries.proposalCount({ chainId: this.dao.chainId, - contractAddress: this.info.address, + contractAddress: this.address, + }) + } + + getDaoAddressQuery(): FetchQueryOptions { + return secretDaoProposalMultipleQueries.dao({ + chainId: this.dao.chainId, + contractAddress: this.address, + }) + } + + getConfigQuery(): FetchQueryOptions { + return secretDaoProposalMultipleQueries.config({ + chainId: this.dao.chainId, + contractAddress: this.address, }) } + + getDepositInfoQuery(): FetchQueryOptions { + return { + queryKey: [ + 'secretMultipleChoiceProposalModule', + 'depositInfo', + { + chainId: this.dao.chainId, + address: this.address, + }, + ], + queryFn: async () => { + if (this.prePropose) { + const { deposit_info: depositInfo } = + await this.queryClient.fetchQuery( + secretDaoPreProposeMultipleQueries.config({ + chainId: this.dao.chainId, + contractAddress: this.prePropose.address, + }) + ) + + return depositInfo + ? { + amount: depositInfo.amount, + denom: + // Convert snip20 to cw20 key. + 'snip20' in depositInfo.denom + ? { + // Code hash. + cw20: depositInfo.denom.snip20[0], + } + : depositInfo.denom, + refund_policy: depositInfo.refund_policy, + } + : null + } + + // If pre-propose is supported but not set, there are no deposits. + return null + }, + } + } + + async getMaxVotingPeriod(): Promise { + return (await this.queryClient.fetchQuery(this.getConfigQuery())) + .max_voting_period + } } diff --git a/packages/stateful/clients/proposal-module/MultipleChoiceProposalModule.ts b/packages/stateful/clients/proposal-module/MultipleChoiceProposalModule.ts index aa8effa51..d171ca21b 100644 --- a/packages/stateful/clients/proposal-module/MultipleChoiceProposalModule.ts +++ b/packages/stateful/clients/proposal-module/MultipleChoiceProposalModule.ts @@ -1,13 +1,18 @@ -import { FetchQueryOptions } from '@tanstack/react-query' +import { FetchQueryOptions, QueryClient } from '@tanstack/react-query' import { DaoPreProposeMultipleClient, DaoProposalMultipleClient, } from '@dao-dao/state/contracts' -import { daoProposalMultipleQueries } from '@dao-dao/state/query' import { + daoPreProposeMultipleQueries, + daoProposalMultipleQueries, +} from '@dao-dao/state/query' +import { + CheckedDepositInfo, Coin, ContractVersion, + Duration, Feature, ModuleInstantiateInfo, } from '@dao-dao/types' @@ -16,11 +21,12 @@ import { UncheckedDepositInfo, } from '@dao-dao/types/contracts/DaoPreProposeMultiple' import { - Duration, + Config, InstantiateMsg, MultipleChoiceVote, PercentageThreshold, PreProposeInfo, + ProposalResponse, VetoConfig, VoteInfo, VoteResponse, @@ -41,9 +47,11 @@ import { ProposalModuleBase } from './base' export class MultipleChoiceProposalModule extends ProposalModuleBase< CwDao, NewProposalData, + ProposalResponse, VoteResponse, VoteInfo, - MultipleChoiceVote + MultipleChoiceVote, + Config > { static contractNames: readonly string[] = DAO_PROPOSAL_MULTIPLE_CONTRACT_NAMES @@ -165,6 +173,19 @@ export class MultipleChoiceProposalModule extends ProposalModuleBase< } } + /** + * Query options to fetch the DAO address. + */ + static getDaoAddressQuery( + queryClient: QueryClient, + options: { + chainId: string + contractAddress: string + } + ) { + return daoProposalMultipleQueries.dao(queryClient, options) + } + async propose({ data, getSigningClient, @@ -310,6 +331,26 @@ export class MultipleChoiceProposalModule extends ProposalModuleBase< }) } + getProposalQuery({ + proposalId, + }: { + proposalId: number + }): FetchQueryOptions { + return daoProposalMultipleQueries.proposal(this.queryClient, { + chainId: this.dao.chainId, + contractAddress: this.address, + args: { + proposalId, + }, + }) + } + + async getProposal( + ...params: Parameters + ): Promise { + return await this.queryClient.fetchQuery(this.getProposalQuery(...params)) + } + getVoteQuery({ proposalId, voter, @@ -319,7 +360,7 @@ export class MultipleChoiceProposalModule extends ProposalModuleBase< }): FetchQueryOptions { return daoProposalMultipleQueries.getVote(this.queryClient, { chainId: this.dao.chainId, - contractAddress: this.info.address, + contractAddress: this.address, args: { proposalId, ...(voter && { voter }), @@ -349,4 +390,52 @@ export class MultipleChoiceProposalModule extends ProposalModuleBase< contractAddress: this.info.address, }) } + + getDaoAddressQuery(): FetchQueryOptions { + return daoProposalMultipleQueries.dao(this.queryClient, { + chainId: this.dao.chainId, + contractAddress: this.address, + }) + } + + getConfigQuery(): FetchQueryOptions { + return daoProposalMultipleQueries.config(this.queryClient, { + chainId: this.dao.chainId, + contractAddress: this.address, + }) + } + + getDepositInfoQuery(): FetchQueryOptions { + return { + queryKey: [ + 'multipleChoiceProposalModule', + 'depositInfo', + { + chainId: this.dao.chainId, + address: this.address, + }, + ], + queryFn: async () => { + if (this.prePropose) { + const { deposit_info: depositInfo } = + await this.queryClient.fetchQuery( + daoPreProposeMultipleQueries.config(this.queryClient, { + chainId: this.dao.chainId, + contractAddress: this.prePropose.address, + }) + ) + + return depositInfo || null + } + + // If pre-propose is supported but not set, there are no deposits. + return null + }, + } + } + + async getMaxVotingPeriod(): Promise { + return (await this.queryClient.fetchQuery(this.getConfigQuery())) + .max_voting_period + } } diff --git a/packages/stateful/clients/proposal-module/SingleChoiceProposalModule.secret.ts b/packages/stateful/clients/proposal-module/SingleChoiceProposalModule.secret.ts index 3105bf254..47c9a7d53 100644 --- a/packages/stateful/clients/proposal-module/SingleChoiceProposalModule.secret.ts +++ b/packages/stateful/clients/proposal-module/SingleChoiceProposalModule.secret.ts @@ -1,20 +1,29 @@ -import { FetchQueryOptions } from '@tanstack/react-query' +import { FetchQueryOptions, QueryClient } from '@tanstack/react-query' import { SecretDaoPreProposeSingleClient, SecretDaoProposalSingleClient, } from '@dao-dao/state/contracts' -import { secretDaoProposalSingleQueries } from '@dao-dao/state/query' -import { Coin, SecretModuleInstantiateInfo } from '@dao-dao/types' +import { + secretDaoPreProposeSingleQueries, + secretDaoProposalSingleQueries, +} from '@dao-dao/state/query' +import { + CheckedDepositInfo, + Coin, + Duration, + SecretModuleInstantiateInfo, +} from '@dao-dao/types' import { InstantiateMsg as SecretDaoPreProposeApprovalSingleInstantiateMsg } from '@dao-dao/types/contracts/SecretDaoPreProposeApprovalSingle' import { InstantiateMsg as SecretDaoPreProposeSingleInstantiateMsg, UncheckedDepositInfo, } from '@dao-dao/types/contracts/SecretDaoPreProposeSingle' import { - Duration, + Config, InstantiateMsg, PreProposeInfo, + ProposalResponse, Threshold, VetoConfig, Vote, @@ -37,9 +46,11 @@ import { ProposalModuleBase } from './base' export class SecretSingleChoiceProposalModule extends ProposalModuleBase< SecretCwDao, NewProposalData, + ProposalResponse, VoteResponse, VoteInfo, - Vote + Vote, + Config > { static contractNames: readonly string[] = DAO_PROPOSAL_SINGLE_CONTRACT_NAMES @@ -131,6 +142,19 @@ export class SecretSingleChoiceProposalModule extends ProposalModuleBase< } } + /** + * Query options to fetch the DAO address. + */ + static getDaoAddressQuery( + _: QueryClient, + options: { + chainId: string + contractAddress: string + } + ) { + return secretDaoProposalSingleQueries.dao(options) + } + async propose({ data, getSigningClient, @@ -307,6 +331,26 @@ export class SecretSingleChoiceProposalModule extends ProposalModuleBase< ) } + getProposalQuery({ + proposalId, + }: { + proposalId: number + }): FetchQueryOptions { + return secretDaoProposalSingleQueries.proposal({ + chainId: this.dao.chainId, + contractAddress: this.address, + args: { + proposalId, + }, + }) + } + + async getProposal( + ...params: Parameters + ): Promise { + return await this.queryClient.fetchQuery(this.getProposalQuery(...params)) + } + getVoteQuery({ proposalId, voter, @@ -356,7 +400,61 @@ export class SecretSingleChoiceProposalModule extends ProposalModuleBase< getProposalCountQuery(): FetchQueryOptions { return secretDaoProposalSingleQueries.proposalCount({ chainId: this.dao.chainId, - contractAddress: this.info.address, + contractAddress: this.address, + }) + } + + getConfigQuery(): FetchQueryOptions { + return secretDaoProposalSingleQueries.config({ + chainId: this.dao.chainId, + contractAddress: this.address, }) } + + getDepositInfoQuery(): FetchQueryOptions { + return { + queryKey: [ + 'secretSingleChoiceProposalModule', + 'depositInfo', + { + chainId: this.dao.chainId, + address: this.address, + }, + ], + queryFn: async () => { + if (this.prePropose) { + const { deposit_info: depositInfo } = + await this.queryClient.fetchQuery( + secretDaoPreProposeSingleQueries.config({ + chainId: this.dao.chainId, + contractAddress: this.prePropose.address, + }) + ) + + return depositInfo + ? { + amount: depositInfo.amount, + denom: + // Convert snip20 to cw20 key. + 'snip20' in depositInfo.denom + ? { + // Code hash. + cw20: depositInfo.denom.snip20[0], + } + : depositInfo.denom, + refund_policy: depositInfo.refund_policy, + } + : null + } + + // If pre-propose is supported but not set, there are no deposits. + return null + }, + } + } + + async getMaxVotingPeriod(): Promise { + return (await this.queryClient.fetchQuery(this.getConfigQuery())) + .max_voting_period + } } diff --git a/packages/stateful/clients/proposal-module/SingleChoiceProposalModule.ts b/packages/stateful/clients/proposal-module/SingleChoiceProposalModule.ts index 1d5a669fc..27180a1ee 100644 --- a/packages/stateful/clients/proposal-module/SingleChoiceProposalModule.ts +++ b/packages/stateful/clients/proposal-module/SingleChoiceProposalModule.ts @@ -1,4 +1,4 @@ -import { FetchQueryOptions } from '@tanstack/react-query' +import { FetchQueryOptions, QueryClient } from '@tanstack/react-query' import { CwProposalSingleV1Client, @@ -9,9 +9,13 @@ import { cwProposalSingleV1Queries, daoProposalSingleV2Queries, } from '@dao-dao/state/query' +import { daoPreProposeSingleQueries } from '@dao-dao/state/query/queries/contracts/DaoPreProposeSingle' import { + CheckedDepositInfo, Coin, ContractVersion, + DepositRefundPolicy, + Duration, Feature, ModuleInstantiateInfo, } from '@dao-dao/types' @@ -21,9 +25,10 @@ import { UncheckedDepositInfo, } from '@dao-dao/types/contracts/DaoPreProposeSingle' import { - Duration, + Config, InstantiateMsg, PreProposeInfo, + ProposalResponse, Threshold, VetoConfig, Vote, @@ -47,9 +52,11 @@ import { ProposalModuleBase } from './base' export class SingleChoiceProposalModule extends ProposalModuleBase< CwDao, NewProposalData, + ProposalResponse, VoteResponse, VoteInfo, - Vote + Vote, + Config > { static contractNames: readonly string[] = DAO_PROPOSAL_SINGLE_CONTRACT_NAMES @@ -154,6 +161,19 @@ export class SingleChoiceProposalModule extends ProposalModuleBase< } } + /** + * Query options to fetch the DAO address. + */ + static getDaoAddressQuery( + queryClient: QueryClient, + options: { + chainId: string + contractAddress: string + } + ) { + return daoProposalSingleV2Queries.dao(queryClient, options) + } + async propose({ data, getSigningClient, @@ -360,6 +380,26 @@ export class SingleChoiceProposalModule extends ProposalModuleBase< }) } + getProposalQuery({ + proposalId, + }: { + proposalId: number + }): FetchQueryOptions { + return daoProposalSingleV2Queries.proposal(this.queryClient, { + chainId: this.dao.chainId, + contractAddress: this.address, + args: { + proposalId, + }, + }) + } + + async getProposal( + ...params: Parameters + ): Promise { + return await this.queryClient.fetchQuery(this.getProposalQuery(...params)) + } + getVoteQuery({ proposalId, voter, @@ -374,7 +414,7 @@ export class SingleChoiceProposalModule extends ProposalModuleBase< return query(this.queryClient, { chainId: this.dao.chainId, - contractAddress: this.info.address, + contractAddress: this.address, args: { proposalId, ...(voter && { voter }), @@ -406,7 +446,72 @@ export class SingleChoiceProposalModule extends ProposalModuleBase< return query(this.queryClient, { chainId: this.dao.chainId, - contractAddress: this.info.address, + contractAddress: this.address, + }) + } + + getConfigQuery(): FetchQueryOptions { + return daoProposalSingleV2Queries.config(this.queryClient, { + chainId: this.dao.chainId, + contractAddress: this.address, }) } + + getDepositInfoQuery(): FetchQueryOptions { + return { + queryKey: [ + 'singleChoiceProposalModule', + 'depositInfo', + { + chainId: this.dao.chainId, + address: this.address, + }, + ], + queryFn: async () => { + if (this.prePropose) { + const { deposit_info: depositInfo } = + await this.queryClient.fetchQuery( + daoPreProposeSingleQueries.config(this.queryClient, { + chainId: this.dao.chainId, + contractAddress: this.prePropose.address, + }) + ) + + return depositInfo || null + } else if ( + // V1 has proposal deposits built right into the proposal module + // instead of a separate pre-propose module. + !isFeatureSupportedByVersion(Feature.PrePropose, this.version) + ) { + const { deposit_info: depositInfo } = + await this.queryClient.fetchQuery( + cwProposalSingleV1Queries.config(this.queryClient, { + chainId: this.dao.chainId, + contractAddress: this.address, + }) + ) + + return depositInfo + ? { + amount: depositInfo.deposit, + denom: { + cw20: depositInfo.token, + }, + refund_policy: depositInfo.refund_failed_proposals + ? DepositRefundPolicy.Always + : DepositRefundPolicy.OnlyPassed, + } + : null + } + + // If pre-propose is supported but not set, there are no deposits. + return null + }, + } + } + + async getMaxVotingPeriod(): Promise { + return (await this.queryClient.fetchQuery(this.getConfigQuery())) + .max_voting_period + } } diff --git a/packages/stateful/clients/proposal-module/base.ts b/packages/stateful/clients/proposal-module/base.ts index ba68b1214..022b564f7 100644 --- a/packages/stateful/clients/proposal-module/base.ts +++ b/packages/stateful/clients/proposal-module/base.ts @@ -2,21 +2,34 @@ import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate' import { FetchQueryOptions, QueryClient } from '@tanstack/react-query' import { + CheckedDepositInfo, Coin, ContractVersion, + Duration, IDaoBase, IProposalModuleBase, PreProposeModule, - ProposalModule, + ProposalModuleInfo, } from '@dao-dao/types' export abstract class ProposalModuleBase< Dao extends IDaoBase = IDaoBase, Proposal = any, + ProposalResponse = any, VoteResponse = any, VoteInfo = any, - Vote = any -> implements IProposalModuleBase + Vote = any, + Config = any +> implements + IProposalModuleBase< + Dao, + Proposal, + ProposalResponse, + VoteResponse, + VoteInfo, + Vote, + Config + > { /** * The contract names that this module supports. @@ -26,7 +39,7 @@ export abstract class ProposalModuleBase< constructor( protected readonly queryClient: QueryClient, public readonly dao: Dao, - public readonly info: ProposalModule + public readonly info: ProposalModuleInfo ) {} /** @@ -106,6 +119,20 @@ export abstract class ProposalModuleBase< sender: string }): Promise + /** + * Query options to fetch a proposal. + */ + abstract getProposalQuery(options: { + proposalId: number + }): FetchQueryOptions + + /** + * Fetch a proposal. + */ + abstract getProposal(options: { + proposalId: number + }): Promise + /** * Query options to fetch the vote on a proposal by a given address. If voter * is undefined, will return query in loading state. @@ -139,4 +166,25 @@ export abstract class ProposalModuleBase< this.getProposalCountQuery(...params) ) } + + /** + * Query options to fetch the config. + */ + abstract getConfigQuery(): Pick< + FetchQueryOptions, + 'queryKey' | 'queryFn' + > + + /** + * Query options to fetch configured deposit info, if any. + */ + abstract getDepositInfoQuery(): Pick< + FetchQueryOptions, + 'queryKey' | 'queryFn' + > + + /** + * Fetch the max voting period. + */ + abstract getMaxVotingPeriod(): Promise } diff --git a/packages/stateful/clients/proposal-module/getProposalModule.ts b/packages/stateful/clients/proposal-module/getProposalModule.ts new file mode 100644 index 000000000..60c3c9f66 --- /dev/null +++ b/packages/stateful/clients/proposal-module/getProposalModule.ts @@ -0,0 +1,98 @@ +import { QueryClient } from '@tanstack/react-query' + +import { contractQueries } from '@dao-dao/state/query' +import { IProposalModuleBase } from '@dao-dao/types' +import { isSecretNetwork } from '@dao-dao/utils' + +import { fetchProposalModule } from '../../utils' +import { getDao } from '../dao' +import { getProposalModuleBases as cwGetProposalModuleBases } from '../dao/CwDao' +import { getProposalModuleBases as secretCwGetProposalModuleBases } from '../dao/SecretCwDao' + +/** + * Returns the class of the proposal module client based on the provided chain + * ID and address. + */ +export const getProposalModuleType = async ({ + queryClient, + chainId, + address, +}: { + queryClient: QueryClient + chainId: string + address: string +}) => { + const bases = isSecretNetwork(chainId) + ? secretCwGetProposalModuleBases() + : cwGetProposalModuleBases() + + const { + info: { contract }, + } = await queryClient.fetchQuery( + contractQueries.info(queryClient, { + chainId, + address, + }) + ) + + const ProposalModuleType = bases.find((Base) => + Base.contractNames.includes(contract) + ) + if (!ProposalModuleType) { + throw new Error('Unrecognized proposal module contract: ' + contract) + } + + return ProposalModuleType +} + +/** + * Returns the correct proposal module client based on the provided chain ID and + * address. + */ +export const getProposalModule = async ({ + queryClient, + chainId, + address, +}: { + queryClient: QueryClient + chainId: string + address: string +}): Promise => { + const ProposalModuleType = await getProposalModuleType({ + queryClient, + chainId, + address, + }) + + const daoAddress = await queryClient.fetchQuery( + ProposalModuleType.getDaoAddressQuery(queryClient, { + chainId, + contractAddress: address, + }) + ) + + const dao = getDao({ + queryClient, + chainId, + coreAddress: daoAddress, + }) + await dao.init() + + // Find proposal module in the DAO. If non-existent, just make a new one. + return ( + dao.proposalModules.find((m) => m.address === address) || + new ProposalModuleType( + queryClient, + // Force type-cast since it must be the right kind of DAO. + dao as any, + await fetchProposalModule({ + queryClient, + chainId, + address, + // No prefix since the DAO doesn't seem to have this proposal module + // registered. + prefix: '', + }) + ) + ) +} diff --git a/packages/stateful/clients/proposal-module/index.ts b/packages/stateful/clients/proposal-module/index.ts index 4853e659c..a7a45c747 100644 --- a/packages/stateful/clients/proposal-module/index.ts +++ b/packages/stateful/clients/proposal-module/index.ts @@ -2,3 +2,5 @@ export * from './SingleChoiceProposalModule' export * from './MultipleChoiceProposalModule' export * from './SingleChoiceProposalModule.secret' export * from './MultipleChoiceProposalModule.secret' + +export * from './getProposalModule' diff --git a/packages/stateful/clients/voting-module/Cw20StakedVotingModule.ts b/packages/stateful/clients/voting-module/Cw20StakedVotingModule.ts index 20de1e9de..20776e242 100644 --- a/packages/stateful/clients/voting-module/Cw20StakedVotingModule.ts +++ b/packages/stateful/clients/voting-module/Cw20StakedVotingModule.ts @@ -1,7 +1,12 @@ import { FetchQueryOptions, skipToken } from '@tanstack/react-query' -import { daoVotingCw20StakedQueries } from '@dao-dao/state/query' -import { ActiveThreshold, ModuleInstantiateInfo } from '@dao-dao/types' +import { daoVotingCw20StakedQueries, tokenQueries } from '@dao-dao/state/query' +import { + ActiveThreshold, + GenericToken, + ModuleInstantiateInfo, + TokenType, +} from '@dao-dao/types' import { Cw20Coin, InstantiateMarketingInfo, @@ -171,4 +176,35 @@ export class Cw20StakedVotingModule extends VotingModuleBase { }, }) } + + getGovernanceTokenQuery = (): FetchQueryOptions => { + return { + queryKey: [ + 'cw20StakedVotingModule', + 'governanceToken', + { + chainId: this.dao.chainId, + address: this.address, + }, + ], + queryFn: async () => { + const governanceTokenAddress = await this.queryClient.fetchQuery( + daoVotingCw20StakedQueries.tokenContract(this.queryClient, { + chainId: this.dao.chainId, + contractAddress: this.address, + }) + ) + + const token = await this.queryClient.fetchQuery( + tokenQueries.info(this.queryClient, { + chainId: this.dao.chainId, + type: TokenType.Cw20, + denomOrAddress: governanceTokenAddress, + }) + ) + + return token + }, + } + } } diff --git a/packages/stateful/clients/voting-module/Cw721StakedVotingModule.ts b/packages/stateful/clients/voting-module/Cw721StakedVotingModule.ts index b6eb2bb9f..60d36e075 100644 --- a/packages/stateful/clients/voting-module/Cw721StakedVotingModule.ts +++ b/packages/stateful/clients/voting-module/Cw721StakedVotingModule.ts @@ -1,7 +1,16 @@ import { FetchQueryOptions, skipToken } from '@tanstack/react-query' -import { daoVotingCw721StakedQueries } from '@dao-dao/state/query' -import { Coin, ModuleInstantiateInfo, WasmMsg } from '@dao-dao/types' +import { + cw721BaseQueries, + daoVotingCw721StakedQueries, +} from '@dao-dao/state/query' +import { + Coin, + GenericToken, + ModuleInstantiateInfo, + TokenType, + WasmMsg, +} from '@dao-dao/types' import { ExecuteMsg as Cw721BaseExecuteMsg, InstantiateMsg as Cw721BaseInstantiateMsg, @@ -162,4 +171,46 @@ export class Cw721StakedVotingModule extends VotingModuleBase { }, }) } + + getGovernanceTokenQuery = (): FetchQueryOptions => { + return { + queryKey: [ + 'cw721StakedVotingModule', + 'governanceToken', + { + chainId: this.dao.chainId, + address: this.address, + }, + ], + queryFn: async () => { + const { nft_address: collectionAddress } = + await this.queryClient.fetchQuery( + daoVotingCw721StakedQueries.config(this.queryClient, { + chainId: this.dao.chainId, + contractAddress: this.address, + }) + ) + + const contractInfo = await this.queryClient.fetchQuery( + cw721BaseQueries.contractInfo({ + chainId: this.dao.chainId, + contractAddress: collectionAddress, + }) + ) + + return { + chainId: this.dao.chainId, + type: TokenType.Cw721, + denomOrAddress: collectionAddress, + symbol: contractInfo.symbol, + decimals: 0, + source: { + chainId: this.dao.chainId, + type: TokenType.Cw721, + denomOrAddress: collectionAddress, + }, + } + }, + } + } } diff --git a/packages/stateful/clients/voting-module/NativeStakedVotingModule.ts b/packages/stateful/clients/voting-module/NativeStakedVotingModule.ts index 758a143f9..f771656de 100644 --- a/packages/stateful/clients/voting-module/NativeStakedVotingModule.ts +++ b/packages/stateful/clients/voting-module/NativeStakedVotingModule.ts @@ -1,6 +1,10 @@ import { FetchQueryOptions, skipToken } from '@tanstack/react-query' -import { daoVotingNativeStakedQueries } from '@dao-dao/state/query' +import { + daoVotingNativeStakedQueries, + tokenQueries, +} from '@dao-dao/state/query' +import { GenericToken, TokenType } from '@dao-dao/types' import { TotalPowerAtHeightResponse, VotingPowerAtHeightResponse, @@ -57,4 +61,35 @@ export class NativeStakedVotingModule extends VotingModuleBase { }, }) } + + getGovernanceTokenQuery = (): FetchQueryOptions => { + return { + queryKey: [ + 'nativeStakedVotingModule', + 'governanceToken', + { + chainId: this.dao.chainId, + address: this.address, + }, + ], + queryFn: async () => { + const { denom } = await this.queryClient.fetchQuery( + daoVotingNativeStakedQueries.getConfig(this.queryClient, { + chainId: this.dao.chainId, + contractAddress: this.address, + }) + ) + + const token = await this.queryClient.fetchQuery( + tokenQueries.info(this.queryClient, { + chainId: this.dao.chainId, + type: TokenType.Native, + denomOrAddress: denom, + }) + ) + + return token + }, + } + } } diff --git a/packages/stateful/clients/voting-module/OnftStakedVotingModule.ts b/packages/stateful/clients/voting-module/OnftStakedVotingModule.ts index d23a705f2..6bff42687 100644 --- a/packages/stateful/clients/voting-module/OnftStakedVotingModule.ts +++ b/packages/stateful/clients/voting-module/OnftStakedVotingModule.ts @@ -1,7 +1,10 @@ import { FetchQueryOptions, skipToken } from '@tanstack/react-query' -import { daoVotingOnftStakedQueries } from '@dao-dao/state/query' -import { ModuleInstantiateInfo } from '@dao-dao/types' +import { + daoVotingOnftStakedQueries, + omniflixQueries, +} from '@dao-dao/state/query' +import { GenericToken, ModuleInstantiateInfo, TokenType } from '@dao-dao/types' import { ActiveThreshold, Duration, @@ -96,4 +99,46 @@ export class OnftStakedVotingModule extends VotingModuleBase { }, }) } + + getGovernanceTokenQuery = (): FetchQueryOptions => { + return { + queryKey: [ + 'onftStakedVotingModule', + 'governanceToken', + { + chainId: this.dao.chainId, + address: this.address, + }, + ], + queryFn: async () => { + const { onft_collection_id } = await this.queryClient.fetchQuery( + daoVotingOnftStakedQueries.config(this.queryClient, { + chainId: this.dao.chainId, + contractAddress: this.address, + }) + ) + + const { symbol, previewUri } = await this.queryClient.fetchQuery( + omniflixQueries.onftCollectionInfo({ + chainId: this.dao.chainId, + id: onft_collection_id, + }) + ) + + return { + chainId: this.dao.chainId, + type: TokenType.Onft, + denomOrAddress: onft_collection_id, + symbol, + decimals: 0, + imageUrl: previewUri, + source: { + chainId: this.dao.chainId, + type: TokenType.Onft, + denomOrAddress: onft_collection_id, + }, + } + }, + } + } } diff --git a/packages/stateful/clients/voting-module/Snip20StakedVotingModule.secret.ts b/packages/stateful/clients/voting-module/Snip20StakedVotingModule.secret.ts index a8eb5b39b..8f85f8e87 100644 --- a/packages/stateful/clients/voting-module/Snip20StakedVotingModule.secret.ts +++ b/packages/stateful/clients/voting-module/Snip20StakedVotingModule.secret.ts @@ -1,7 +1,14 @@ import { FetchQueryOptions, skipToken } from '@tanstack/react-query' -import { secretDaoVotingSnip20StakedQueries } from '@dao-dao/state/query' -import { SecretModuleInstantiateInfo } from '@dao-dao/types' +import { + secretDaoVotingSnip20StakedQueries, + tokenQueries, +} from '@dao-dao/state/query' +import { + GenericToken, + SecretModuleInstantiateInfo, + TokenType, +} from '@dao-dao/types' import { ActiveThreshold, Duration, @@ -209,4 +216,36 @@ export class SecretSnip20StakedVotingModule extends VotingModuleBase => { + return { + queryKey: [ + 'snip20StakedVotingModule', + 'governanceToken', + { + chainId: this.dao.chainId, + address: this.address, + }, + ], + queryFn: async () => { + const { addr: governanceTokenAddress } = + await this.queryClient.fetchQuery( + secretDaoVotingSnip20StakedQueries.tokenContract({ + chainId: this.dao.chainId, + contractAddress: this.address, + }) + ) + + const token = await this.queryClient.fetchQuery( + tokenQueries.info(this.queryClient, { + chainId: this.dao.chainId, + type: TokenType.Cw20, + denomOrAddress: governanceTokenAddress, + }) + ) + + return token + }, + } + } } diff --git a/packages/stateful/clients/voting-module/Snip721StakedVotingModule.secret.ts b/packages/stateful/clients/voting-module/Snip721StakedVotingModule.secret.ts index 76fc7c121..7c78c7cbd 100644 --- a/packages/stateful/clients/voting-module/Snip721StakedVotingModule.secret.ts +++ b/packages/stateful/clients/voting-module/Snip721StakedVotingModule.secret.ts @@ -1,7 +1,14 @@ import { FetchQueryOptions, skipToken } from '@tanstack/react-query' -import { secretDaoVotingSnip721StakedQueries } from '@dao-dao/state/query' -import { SecretModuleInstantiateInfo } from '@dao-dao/types' +import { + cw721BaseQueries, + secretDaoVotingSnip721StakedQueries, +} from '@dao-dao/state/query' +import { + GenericToken, + SecretModuleInstantiateInfo, + TokenType, +} from '@dao-dao/types' import { ActiveThreshold, Duration, @@ -124,4 +131,46 @@ export class SecretSnip721StakedVotingModule extends VotingModuleBase => { + return { + queryKey: [ + 'snip721StakedVotingModule', + 'governanceToken', + { + chainId: this.dao.chainId, + address: this.address, + }, + ], + queryFn: async () => { + const { nft_address: collectionAddress } = + await this.queryClient.fetchQuery( + secretDaoVotingSnip721StakedQueries.config({ + chainId: this.dao.chainId, + contractAddress: this.address, + }) + ) + + const contractInfo = await this.queryClient.fetchQuery( + cw721BaseQueries.contractInfo({ + chainId: this.dao.chainId, + contractAddress: collectionAddress, + }) + ) + + return { + chainId: this.dao.chainId, + type: TokenType.Cw721, + denomOrAddress: collectionAddress, + symbol: contractInfo.symbol, + decimals: 0, + source: { + chainId: this.dao.chainId, + type: TokenType.Cw721, + denomOrAddress: collectionAddress, + }, + } + }, + } + } } diff --git a/packages/stateful/clients/voting-module/TokenStakedVotingModule.secret.ts b/packages/stateful/clients/voting-module/TokenStakedVotingModule.secret.ts index bb91028bf..a76c22763 100644 --- a/packages/stateful/clients/voting-module/TokenStakedVotingModule.secret.ts +++ b/packages/stateful/clients/voting-module/TokenStakedVotingModule.secret.ts @@ -1,7 +1,14 @@ import { FetchQueryOptions, skipToken } from '@tanstack/react-query' -import { secretDaoVotingTokenStakedQueries } from '@dao-dao/state/query' -import { SecretModuleInstantiateInfo } from '@dao-dao/types' +import { + secretDaoVotingTokenStakedQueries, + tokenQueries, +} from '@dao-dao/state/query' +import { + GenericToken, + SecretModuleInstantiateInfo, + TokenType, +} from '@dao-dao/types' import { ActiveThreshold, Duration, @@ -123,4 +130,35 @@ export class SecretTokenStakedVotingModule extends VotingModuleBase }, }) } + + getGovernanceTokenQuery = (): FetchQueryOptions => { + return { + queryKey: [ + 'secretTokenStakedVotingModule', + 'governanceToken', + { + chainId: this.dao.chainId, + address: this.address, + }, + ], + queryFn: async () => { + const { denom } = await this.queryClient.fetchQuery( + secretDaoVotingTokenStakedQueries.denom({ + chainId: this.dao.chainId, + contractAddress: this.address, + }) + ) + + const token = await this.queryClient.fetchQuery( + tokenQueries.info(this.queryClient, { + chainId: this.dao.chainId, + type: TokenType.Native, + denomOrAddress: denom, + }) + ) + + return token + }, + } + } } diff --git a/packages/stateful/clients/voting-module/TokenStakedVotingModule.ts b/packages/stateful/clients/voting-module/TokenStakedVotingModule.ts index 4f347c727..32f0bfee3 100644 --- a/packages/stateful/clients/voting-module/TokenStakedVotingModule.ts +++ b/packages/stateful/clients/voting-module/TokenStakedVotingModule.ts @@ -1,7 +1,13 @@ import { FetchQueryOptions, skipToken } from '@tanstack/react-query' -import { daoVotingTokenStakedQueries } from '@dao-dao/state/query' -import { Coin, ModuleInstantiateInfo, WasmMsg } from '@dao-dao/types' +import { daoVotingTokenStakedQueries, tokenQueries } from '@dao-dao/state/query' +import { + Coin, + GenericToken, + ModuleInstantiateInfo, + TokenType, + WasmMsg, +} from '@dao-dao/types' import { ActiveThreshold, Duration, @@ -158,4 +164,35 @@ export class TokenStakedVotingModule extends VotingModuleBase { }, }) } + + getGovernanceTokenQuery = (): FetchQueryOptions => { + return { + queryKey: [ + 'tokenStakedVotingModule', + 'governanceToken', + { + chainId: this.dao.chainId, + address: this.address, + }, + ], + queryFn: async () => { + const { denom } = await this.queryClient.fetchQuery( + daoVotingTokenStakedQueries.denom(this.queryClient, { + chainId: this.dao.chainId, + contractAddress: this.address, + }) + ) + + const token = await this.queryClient.fetchQuery( + tokenQueries.info(this.queryClient, { + chainId: this.dao.chainId, + type: TokenType.Native, + denomOrAddress: denom, + }) + ) + + return token + }, + } + } } diff --git a/packages/stateful/clients/voting-module/base.ts b/packages/stateful/clients/voting-module/base.ts index d663f041c..8fb3be0c1 100644 --- a/packages/stateful/clients/voting-module/base.ts +++ b/packages/stateful/clients/voting-module/base.ts @@ -3,6 +3,7 @@ import { FetchQueryOptions, QueryClient } from '@tanstack/react-query' import { ContractVersion, ContractVersionInfo, + GenericToken, IDaoBase, IVotingModuleBase, } from '@dao-dao/types' @@ -84,4 +85,13 @@ export abstract class VotingModuleBase ) ).power } + + /** + * Query options to fetch the governance token used by this voting module. Not + * all voting modules have a governance token. + */ + getGovernanceTokenQuery?(): Pick< + FetchQueryOptions, + 'queryKey' | 'queryFn' + > } diff --git a/packages/stateful/components/ButtonLink.tsx b/packages/stateful/components/ButtonLink.tsx index 107274e79..05b184700 100644 --- a/packages/stateful/components/ButtonLink.tsx +++ b/packages/stateful/components/ButtonLink.tsx @@ -18,9 +18,8 @@ export const ButtonLink = forwardRef( loading={loading || navigating} onClick={(event) => { onClick?.(event) - // Update global loading state. - if (href && !props.openInNewTab && !href.startsWith('http')) { + if (!props.openInNewTab) { updateNavigatingHref(href) } }} diff --git a/packages/stateful/components/IconButtonLink.tsx b/packages/stateful/components/IconButtonLink.tsx index 6f2c970ff..168986d5a 100644 --- a/packages/stateful/components/IconButtonLink.tsx +++ b/packages/stateful/components/IconButtonLink.tsx @@ -19,7 +19,9 @@ export const IconButtonLink = forwardRef( onClick={(event) => { onClick?.(event) // Update global loading state. - updateNavigatingHref(href) + if (!props.openInNewTab) { + updateNavigatingHref(href) + } }} ref={ref} > diff --git a/packages/stateful/components/LinkWrapper.tsx b/packages/stateful/components/LinkWrapper.tsx index fb1f4ca69..fbc83159a 100644 --- a/packages/stateful/components/LinkWrapper.tsx +++ b/packages/stateful/components/LinkWrapper.tsx @@ -19,7 +19,9 @@ export const LinkWrapper = forwardRef< onClick={(event) => { onClick?.(event) // Update global loading state. - updateNavigatingHref(href) + if (!props.openInNewTab) { + updateNavigatingHref(href) + } }} ref={ref} > diff --git a/packages/stateful/components/PayEntityDisplay.tsx b/packages/stateful/components/PayEntityDisplay.tsx index 94eec2df6..a46307c49 100644 --- a/packages/stateful/components/PayEntityDisplay.tsx +++ b/packages/stateful/components/PayEntityDisplay.tsx @@ -1,14 +1,12 @@ -import { waitForAll } from 'recoil' - -import { genericTokenSelector } from '@dao-dao/state/recoil' import { + ErrorPage, Loader, PayEntityDisplay as StatelessPayEntityDisplay, - useCachedLoading, useChain, } from '@dao-dao/stateless' import { StatefulPayEntityDisplayProps, TokenType } from '@dao-dao/types' +import { useQueryTokens } from '../hooks' import { EntityDisplay } from './EntityDisplay' export const PayEntityDisplay = ({ @@ -17,21 +15,18 @@ export const PayEntityDisplay = ({ }: StatefulPayEntityDisplayProps) => { const { chain_id: chainId } = useChain() - const tokens = useCachedLoading( - waitForAll( - coins.map(({ denom }) => - genericTokenSelector({ - chainId, - type: TokenType.Native, - denomOrAddress: denom, - }) - ) - ), - [] + const tokens = useQueryTokens( + coins.map(({ denom }) => ({ + chainId, + type: TokenType.Native, + denomOrAddress: denom, + })) ) return tokens.loading ? ( + ) : tokens.errored ? ( + ) : ( const InnerProposalLine = ({ proposalViewUrl, onClick, isPreProposeProposal, + openInNewTab, }: InnerProposalLineProps) => { const { t } = useTranslation() const { @@ -82,6 +83,7 @@ const InnerProposalLine = ({ LinkWrapper={LinkWrapper} href={proposalViewUrl} onClick={onClick} + openInNewTab={openInNewTab} /> ) diff --git a/packages/stateful/components/ProposalList.tsx b/packages/stateful/components/ProposalList.tsx index ef52710e0..f4d21ae88 100644 --- a/packages/stateful/components/ProposalList.tsx +++ b/packages/stateful/components/ProposalList.tsx @@ -17,6 +17,8 @@ import { } from '@dao-dao/stateless' import { CommonProposalListInfo, + ProposalStatus, + ProposalStatusEnum, StatefulProposalLineProps, StatefulProposalListProps, } from '@dao-dao/types' @@ -52,6 +54,9 @@ type CommonProposalListInfoWithType = CommonProposalListInfo & { export const ProposalList = ({ onClick, hideVetoable = false, + onlyExecutable = false, + hideNotifier = false, + ...props }: StatefulProposalListProps) => { const { t } = useTranslation() const { dao } = useDaoContext() @@ -60,10 +65,10 @@ export const ProposalList = ({ const { isMember = false } = useMembership() const [openProposals, setOpenProposals] = useState< - StatefulProposalLineProps[] + (StatefulProposalLineProps & { status: ProposalStatus })[] >([]) const [historyProposals, setHistoryProposals] = useState< - StatefulProposalLineProps[] + (StatefulProposalLineProps & { status: ProposalStatus })[] >([]) // Get selectors for all proposal modules so we can list proposals. @@ -262,7 +267,10 @@ export const ProposalList = ({ const transformIntoProps = ({ id, type, - }: typeof newProposalInfos[number]): StatefulProposalLineProps => ({ + status, + }: typeof newProposalInfos[number]): StatefulProposalLineProps & { + status: ProposalStatus + } => ({ chainId: dao.chainId, coreAddress: dao.coreAddress, proposalId: id, @@ -275,18 +283,19 @@ export const ProposalList = ({ isPreProposeProposal: type === ProposalType.PreProposePending || type === ProposalType.PreProposeCompleted, + status, }) newOpenProposals = [ ...newOpenProposals, ...newProposalInfos - .filter(({ isOpen }) => isOpen) + .filter(({ status }) => status === ProposalStatusEnum.Open) .map(transformIntoProps), ] newHistoryProposals = [ ...newHistoryProposals, ...newProposalInfos - .filter(({ isOpen }) => !isOpen) + .filter(({ status }) => status !== ProposalStatusEnum.Open) .map(transformIntoProps), ] @@ -351,7 +360,10 @@ export const ProposalList = ({ return ( loadMore() } loadingMore={loading} - openProposals={openProposals} - sections={[ - { - title: t('title.history'), - proposals: historyProposals, - total: - !loadingProposalCounts.loading && !loadingProposalCounts.errored - ? // Remove open proposals from total history count since they - // are shown above. - loadingProposalCounts.data - openProposals.length - : undefined, - }, - ]} + openProposals={ + // Show executable proposals at the top in place of open proposals. + onlyExecutable + ? historyProposals.filter( + ({ status }) => status === ProposalStatusEnum.Passed + ) + : openProposals + } + sections={ + // Show executable proposals at the top in place of open proposals. + onlyExecutable + ? [] + : [ + { + title: t('title.history'), + proposals: historyProposals, + total: + !loadingProposalCounts.loading && + !loadingProposalCounts.errored + ? // Remove open proposals from total history count since they + // are shown above. + loadingProposalCounts.data - openProposals.length + : undefined, + }, + ] + } /> ) } diff --git a/packages/stateful/components/dao/CreateDaoForm.tsx b/packages/stateful/components/dao/CreateDaoForm.tsx index 08af957b2..e885f4a87 100644 --- a/packages/stateful/components/dao/CreateDaoForm.tsx +++ b/packages/stateful/components/dao/CreateDaoForm.tsx @@ -14,8 +14,11 @@ import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' import { constSelector, useRecoilState, useRecoilValue } from 'recoil' -import { contractQueries } from '@dao-dao/state/query' -import { averageColorSelector, walletChainIdAtom } from '@dao-dao/state/recoil' +import { + averageColorSelector, + contractQueries, + walletChainIdAtom, +} from '@dao-dao/state' import { Button, ChainProvider, @@ -78,7 +81,7 @@ import { versionGte, } from '@dao-dao/utils' -import { CustomData } from '../../actions/core/advanced/Custom/Component' +import { CustomData } from '../../actions/core/actions/Custom/Component' import { CwDao } from '../../clients/dao/CwDao' import { SecretCwDao } from '../../clients/dao/SecretCwDao' import { getCreatorById, getCreators } from '../../creators' diff --git a/packages/stateful/components/dao/CreateDaoProposal.tsx b/packages/stateful/components/dao/CreateDaoProposal.tsx index 3682434d0..ed59dad35 100644 --- a/packages/stateful/components/dao/CreateDaoProposal.tsx +++ b/packages/stateful/components/dao/CreateDaoProposal.tsx @@ -35,7 +35,7 @@ import { BaseNewProposalProps, DaoTabId, ProposalDraft, - ProposalModule, + ProposalModuleInfo, ProposalPrefill, } from '@dao-dao/types' import { @@ -109,8 +109,8 @@ export const CreateDaoProposal = () => { } type InnerCreateDaoProposalProps = { - selectedProposalModule: ProposalModule - setSelectedProposalModule: Dispatch> + selectedProposalModule: ProposalModuleInfo + setSelectedProposalModule: Dispatch> latestProposalSave: any } diff --git a/packages/stateful/components/dao/DaoApproverProposalContentDisplay.tsx b/packages/stateful/components/dao/DaoApproverProposalContentDisplay.tsx index 14530c89a..8a87e0dd1 100644 --- a/packages/stateful/components/dao/DaoApproverProposalContentDisplay.tsx +++ b/packages/stateful/components/dao/DaoApproverProposalContentDisplay.tsx @@ -17,7 +17,6 @@ import { PreProposeModuleType, } from '@dao-dao/types' -import { useActionsForMatching } from '../../actions' import { useEntity } from '../../hooks' import { ProposalModuleAdapterProvider, @@ -32,7 +31,6 @@ import { DaoProviders } from './DaoProviders' export type DaoApproverProposalContentDisplayProps = { proposalInfo: CommonProposalInfo - setSeenAllActionPages: (() => void) | undefined } type InnerDaoApproverProposalContentDisplayProps = Omit< @@ -157,7 +155,6 @@ const InnerDaoApproverProposalContentDisplay = ( ) const InnerDaoApproverProposalContentDisplayWithInnerContent = ({ - setSeenAllActionPages, ...props }: InnerDaoApproverProposalContentDisplayWithInnerContentProps) => { const { t } = useTranslation() @@ -165,7 +162,6 @@ const InnerDaoApproverProposalContentDisplayWithInnerContent = ({ hooks: { useLoadingPreProposeApprovalProposal }, components: { PreProposeApprovalInnerContentDisplay }, } = useProposalModuleAdapter() - const actionsForMatching = useActionsForMatching() const loadingPreProposeApprovalProposal = useLoadingPreProposeApprovalProposal() @@ -193,12 +189,7 @@ const InnerDaoApproverProposalContentDisplayWithInnerContent = ({ address: creatorAddress, entity, }} - innerContentDisplay={ - - } + innerContentDisplay={} /> ) } diff --git a/packages/stateful/components/dao/DaoPreProposeApprovalProposalContentDisplay.tsx b/packages/stateful/components/dao/DaoPreProposeApprovalProposalContentDisplay.tsx index 1c74461d5..3552d45d0 100644 --- a/packages/stateful/components/dao/DaoPreProposeApprovalProposalContentDisplay.tsx +++ b/packages/stateful/components/dao/DaoPreProposeApprovalProposalContentDisplay.tsx @@ -15,7 +15,6 @@ import { } from '@dao-dao/types' import { encodeJsonToBase64, keyFromPreProposeStatus } from '@dao-dao/utils' -import { useActionsForMatching } from '../../actions' import { useEntity } from '../../hooks' import { useProposalModuleAdapterContext } from '../../proposal-module-adapter' import { EntityDisplay } from '../EntityDisplay' @@ -31,7 +30,6 @@ export const DaoPreProposeApprovalProposalContentDisplay = ({ const { t } = useTranslation() const { coreAddress } = useDaoInfoContext() const { getDaoProposalPath } = useDaoNavHelpers() - const actionsForMatching = useActionsForMatching() const { id, adapter: { @@ -104,7 +102,6 @@ export const DaoPreProposeApprovalProposalContentDisplay = ({ duplicateUrl={duplicateUrl} innerContentDisplay={ } diff --git a/packages/stateful/components/dao/DaoProposal.tsx b/packages/stateful/components/dao/DaoProposal.tsx index 30726188e..0efde5d17 100644 --- a/packages/stateful/components/dao/DaoProposal.tsx +++ b/packages/stateful/components/dao/DaoProposal.tsx @@ -200,17 +200,6 @@ const InnerDaoProposal = ({ proposalInfo }: InnerDaoProposalProps) => { return () => clearInterval(interval) }, [listeningForProposal, listeningForVote, refreshProposalAndAll]) - // Whether or not the user has seen all the action pages. - const [seenAllActionPages, __setSeenAllActionPages] = useState(false) - const _setSeenAllActionPages = useCallback( - () => __setSeenAllActionPages(true), - [] - ) - const setSeenAllActionPages = - // Only set seen all action pages if the user can vote. This prevents the - // warning from appearing if the user can't vote. - canVote ? _setSeenAllActionPages : undefined - // Memoize ProposalStatusAndInfo so it doesn't re-render when the proposal // refreshes. The cached loadable it uses internally depends on the // component's consistency. If we inline the component definition in the props @@ -224,7 +213,6 @@ const InnerDaoProposal = ({ proposalInfo }: InnerDaoProposalProps) => { onVetoSuccess={onVetoSuccess} openSelfRelayExecute={setSelfRelayExecuteProps} voter={{ - seenAllActionPages, onVoteSuccess, }} /> @@ -235,7 +223,6 @@ const InnerDaoProposal = ({ proposalInfo }: InnerDaoProposalProps) => { onExecuteSuccess, onVetoSuccess, onVoteSuccess, - seenAllActionPages, ] ) @@ -273,10 +260,7 @@ const InnerDaoProposal = ({ proposalInfo }: InnerDaoProposalProps) => { commonContext={proposalModuleAdapterCommonContext} context={proposalModuleAdapterContext} > - + ) : undefined @@ -299,19 +283,13 @@ const InnerDaoProposal = ({ proposalInfo }: InnerDaoProposalProps) => { } contentDisplay={ proposalModule.prePropose?.type === PreProposeModuleType.Approver ? ( - + ) : isPreProposeApprovalProposal ? ( ) : ( - + ) } voteTally={ diff --git a/packages/stateful/components/dao/DaoProposalContentDisplay.tsx b/packages/stateful/components/dao/DaoProposalContentDisplay.tsx index dd15b14b8..7101dc2c1 100644 --- a/packages/stateful/components/dao/DaoProposalContentDisplay.tsx +++ b/packages/stateful/components/dao/DaoProposalContentDisplay.tsx @@ -12,7 +12,6 @@ import { } from '@dao-dao/types' import { encodeJsonToBase64 } from '@dao-dao/utils' -import { useActionsForMatching } from '../../actions' import { useEntity } from '../../hooks' import { useProposalModuleAdapterContext } from '../../proposal-module-adapter' import { EntityDisplay } from '../EntityDisplay' @@ -20,16 +19,13 @@ import { IconButtonLink } from '../IconButtonLink' export type DaoProposalContentDisplayProps = { proposalInfo: CommonProposalInfo - setSeenAllActionPages: (() => void) | undefined } export const DaoProposalContentDisplay = ({ proposalInfo, - setSeenAllActionPages, }: DaoProposalContentDisplayProps) => { const { coreAddress } = useDaoInfoContext() const { getDaoProposalPath } = useDaoNavHelpers() - const actionsForMatching = useActionsForMatching() const { id, options: { proposalModule }, @@ -81,9 +77,7 @@ export const DaoProposalContentDisplay = ({ duplicateUrl={duplicateUrl} innerContentDisplay={ } onRefresh={refreshProposal} diff --git a/packages/stateful/components/dao/DaoProviders.tsx b/packages/stateful/components/dao/DaoProviders.tsx index 505fb6b42..efb9be47a 100644 --- a/packages/stateful/components/dao/DaoProviders.tsx +++ b/packages/stateful/components/dao/DaoProviders.tsx @@ -1,5 +1,5 @@ import { useQueryClient } from '@tanstack/react-query' -import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react' +import { ReactNode, useEffect, useMemo, useState } from 'react' import { ChainProvider, @@ -9,31 +9,13 @@ import { Loader, useUpdatingRef, } from '@dao-dao/stateless' -import { LoaderProps } from '@dao-dao/types' +import { DaoProvidersProps } from '@dao-dao/types' import { DaoActionsProvider } from '../../actions' import { ChainXGovDao, SecretCwDao, getDao } from '../../clients/dao' import { useWallet } from '../../hooks' import { VotingModuleAdapterProvider } from '../../voting-module-adapter' -export type DaoProvidersProps = { - chainId: string - /** - * Passing an empty string will start in a loading state. - */ - coreAddress: string - children: ReactNode - /** - * Optionally override the loader with a rendered React node. Takes precedence - * over `LoaderFallback`. - */ - loaderFallback?: ReactNode - /** - * Optionally override the Loader class to be rendered with no props. - */ - LoaderFallback?: ComponentType -} - type InitializedDaoProvidersProps = { context: IDaoContext children: ReactNode @@ -106,35 +88,25 @@ export const DaoProviders = ({ const InitializedDaoProviders = ({ context, children, -}: InitializedDaoProvidersProps) => { - // Don't wrap chain governance in voting module or DAO actions provider. - const inner = - context.dao instanceof ChainXGovDao ? ( - children - ) : ( - - {children} - - ) - - return ( - // Add a unique key here to tell React to re-render everything when the - // `coreAddress` is changed, since for some insane reason, Next.js does not - // reset state when navigating between dynamic rotues. Even though the - // `info` value passed below changes, somehow no re-render occurs... unless - // the `key` prop is unique. See the issue below for more people compaining - // about this to no avail. https://github.com/vercel/next.js/issues/9992 - - - {inner} - - - ) -} +}: InitializedDaoProvidersProps) => ( + // Add a unique key here to tell React to re-render everything when the + // `coreAddress` is changed, since for some insane reason, Next.js does not + // reset state when navigating between dynamic rotues. Even though the `info` + // value passed below changes, somehow no re-render occurs... unless the `key` + // prop is unique. See the issue below for more people compaining about this + // to no avail. https://github.com/vercel/next.js/issues/9992 + + + { + // Don't wrap chain governance in voting module or DAO actions provider. + context.dao instanceof ChainXGovDao ? ( + children + ) : ( + + {children} + + ) + } + + +) diff --git a/packages/stateful/components/dao/DaoTokenCard.tsx b/packages/stateful/components/dao/DaoTokenCard.tsx index 11d2fed06..da02886e3 100644 --- a/packages/stateful/components/dao/DaoTokenCard.tsx +++ b/packages/stateful/components/dao/DaoTokenCard.tsx @@ -35,6 +35,7 @@ import { tokensEqual, } from '@dao-dao/utils' +import { useDaoGovernanceToken } from '../../hooks' import { useVotingModuleAdapter } from '../../voting-module-adapter' import { ButtonLink } from '../ButtonLink' import { EntityDisplay } from '../EntityDisplay' @@ -64,11 +65,10 @@ export const DaoTokenCard = ({ } ) + const governanceTokenInfo = useDaoGovernanceToken() const { - hooks: { useCommonGovernanceTokenInfo }, components: { StakingModal }, } = useVotingModuleAdapter() - const governanceTokenInfo = useCommonGovernanceTokenInfo?.() // If this token is the governance token for the DAO, hide deposit and show // staking modal. const isGovernanceToken = diff --git a/packages/stateful/components/dao/MainDaoInfoCards.tsx b/packages/stateful/components/dao/MainDaoInfoCards.tsx index fbea38b4e..b07c622e0 100644 --- a/packages/stateful/components/dao/MainDaoInfoCards.tsx +++ b/packages/stateful/components/dao/MainDaoInfoCards.tsx @@ -16,7 +16,7 @@ import { formatPercentOf100, } from '@dao-dao/utils' -import { useQueryLoadingData } from '../../hooks' +import { useDaoGovernanceToken, useQueryLoadingData } from '../../hooks' import { useVotingModuleAdapter } from '../../voting-module-adapter' import { EntityDisplay } from '../EntityDisplay' import { SuspenseLoader } from '../SuspenseLoader' @@ -37,10 +37,10 @@ const InnerMainDaoInfoCards = () => { const { t } = useTranslation() const { chain_id: chainId } = useChain() const { - hooks: { useMainDaoInfoCards, useCommonGovernanceTokenInfo }, + hooks: { useMainDaoInfoCards }, } = useVotingModuleAdapter() const votingModuleCards = useMainDaoInfoCards() - const tokenInfo = useCommonGovernanceTokenInfo?.() + const tokenInfo = useDaoGovernanceToken() const { dao } = useDaoContext() const { activeThreshold, created, proposalModules } = dao.info diff --git a/packages/stateful/components/dao/commonVotingConfig/ProposalDepositVotingConfigItem.tsx b/packages/stateful/components/dao/commonVotingConfig/ProposalDepositVotingConfigItem.tsx index 2c1bde52a..ae7f332c7 100644 --- a/packages/stateful/components/dao/commonVotingConfig/ProposalDepositVotingConfigItem.tsx +++ b/packages/stateful/components/dao/commonVotingConfig/ProposalDepositVotingConfigItem.tsx @@ -26,6 +26,7 @@ import { DaoCreationVotingConfigItemReviewProps, DaoCreationVotingConfigWithProposalDeposit, DepositRefundPolicy, + GenericToken, TokenInputOption, TokenType, } from '@dao-dao/types' @@ -59,7 +60,9 @@ const ProposalDepositInput = ({ const isTokenBasedCreator = creator.id === TokenBasedCreatorId const tokenBasedCreatorData = creator.data as TokenBasedCreatorData - const governanceTokenLoadable = useRecoilValueLoadable( + const governanceTokenLoadable = useRecoilValueLoadable< + GenericToken | undefined + >( isTokenBasedCreator ? tokenBasedCreatorData.tokenType === GovernanceTokenType.New ? constSelector({ diff --git a/packages/stateful/components/dao/tabs/AppsTab.tsx b/packages/stateful/components/dao/tabs/AppsTab.tsx index 9091e3d22..f2bf01653 100644 --- a/packages/stateful/components/dao/tabs/AppsTab.tsx +++ b/packages/stateful/components/dao/tabs/AppsTab.tsx @@ -3,13 +3,12 @@ import { fromBech32 } from '@cosmjs/encoding' import { DirectSignDoc, SimpleAccount, WalletAccount } from '@cosmos-kit/core' import { useIframe } from '@cosmos-kit/react-lite' import cloneDeep from 'lodash.clonedeep' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { FormProvider, useForm } from 'react-hook-form' import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' import { useRecoilState, useSetRecoilState } from 'recoil' import useDeepCompareEffect from 'use-deep-compare-effect' -import { v4 as uuidv4 } from 'uuid' import { OverrideHandler } from '@dao-dao/cosmiframe' import { @@ -18,18 +17,22 @@ import { refreshProposalsIdAtom, } from '@dao-dao/state/recoil' import { + ActionCardLoader, + ActionMatcherProvider, + ErrorPage, Loader, Modal, ProfileImage, ProfileNameDisplayAndEditor, AppsTab as StatelessAppsTab, StatusCard, + useActionMatcher, useDaoInfoContext, + useLoadingPromise, } from '@dao-dao/stateless' import { AccountType, ActionKeyAndData, - ActionKeyAndDataNoId, BaseNewProposalProps, ProposalDraft, UnifiedCosmosMsg, @@ -42,15 +45,13 @@ import { DaoProposalSingleAdapterId, SITE_TITLE, SITE_URL, - decodeMessages, getAccountAddress, getAccountChainId, getChainForChainId, getDisplayNameForChainId, - maybeMakePolytoneExecuteMessage, + maybeMakePolytoneExecuteMessages, } from '@dao-dao/utils' -import { useActionsForMatching } from '../../../actions' import { useProfile } from '../../../hooks' import { ProposalModuleAdapterCommonProvider, @@ -87,6 +88,8 @@ export const AppsTab = () => { ) const [msgs, setMsgs] = useState() + const close = useCallback(() => setMsgs(undefined), []) + const [fullScreen, setFullScreen] = useState(false) const addressForChainId = (chainId: string) => { @@ -94,7 +97,7 @@ export const AppsTab = () => { getAccountAddress({ accounts, chainId, - types: [AccountType.Native, AccountType.Polytone], + types: [AccountType.Base, AccountType.Polytone], }) || // Fallback to ICA if exists, but don't use if a native or polytone // account exists. @@ -137,8 +140,8 @@ export const AppsTab = () => { } const encodedMessages = TxBody.decode(signDocBodyBytes).messages - const messages = encodedMessages.map((msg) => - maybeMakePolytoneExecuteMessage( + const messages = encodedMessages.flatMap((msg) => + maybeMakePolytoneExecuteMessages( currentChainId, chainId, protobufToCwMsg(getChainForChainId(chainId), msg, false).msg @@ -153,8 +156,8 @@ export const AppsTab = () => { return } - const messages = signDoc.msgs.map((msg) => - maybeMakePolytoneExecuteMessage( + const messages = signDoc.msgs.flatMap((msg) => + maybeMakePolytoneExecuteMessages( currentChainId, chainId, decodedStargateMsgToCw( @@ -336,11 +339,9 @@ export const AppsTab = () => { - + + + )} @@ -353,13 +354,46 @@ export const AppsTab = () => { } type ActionMatcherAndProposerProps = { - msgs: UnifiedCosmosMsg[] - setMsgs: (msgs: UnifiedCosmosMsg[] | undefined) => void + close: () => void + actionKeysAndData: ActionKeyAndData[] } -const ActionMatcherAndProposer = ({ - msgs, - setMsgs, +const ActionMatcherAndProposer = ( + props: Omit +) => { + const matcher = useActionMatcher() + const data = useLoadingPromise({ + promise: async () => + matcher.ready + ? Promise.all( + matcher.matches.map( + async (decoder, index): Promise => ({ + _id: index.toString(), + actionKey: decoder.action.key, + data: await decoder.decode(), + }) + ) + ) + : ([] as ActionKeyAndData[]), + deps: [matcher.status], + }) + + return data.loading ? ( +
+ + + +
+ ) : data.errored ? ( + + ) : ( + + ) +} + +const InnerActionMatcherAndProposer = ({ + close, + actionKeysAndData, }: ActionMatcherAndProposerProps) => { const { t } = useTranslation() const { coreAddress } = useDaoInfoContext() @@ -373,42 +407,12 @@ const ActionMatcherAndProposer = ({ }, } = useProposalModuleAdapterCommonContext() - const actionsForMatching = useActionsForMatching() - - const decodedMessages = useMemo(() => decodeMessages(msgs), [msgs]) - - // Call relevant action hooks in the same order every time. - const actionData: ActionKeyAndDataNoId[] = decodedMessages.map((message) => { - const actionMatch = actionsForMatching - .map((action) => ({ - action, - ...action.useDecodedCosmosMsg(message), - })) - .find(({ match }) => match) - - // There should always be a match since custom matches all. This should - // never happen as long as the Custom action exists. - if (!actionMatch?.match) { - throw new Error(t('error.loadingData')) - } - - return { - actionKey: actionMatch.action.key, - data: actionMatch.data, - } - }) - const formMethods = useForm({ mode: 'onChange', defaultValues: { title: '', description: '', - actionData: actionData.map( - (data): ActionKeyAndData => ({ - _id: uuidv4(), - ...data, - }) - ), + actionData: actionKeysAndData, }, }) const proposalData = formMethods.watch() @@ -418,14 +422,9 @@ const ActionMatcherAndProposer = ({ formMethods.reset({ title: proposalData.title, description: proposalData.description, - actionData: actionData.map( - (data): ActionKeyAndData => ({ - _id: uuidv4(), - ...data, - }) - ), + actionData: actionKeysAndData, }) - }, [actionData]) + }, [actionKeysAndData]) const setProposalCreatedCardProps = useSetRecoilState( proposalCreatedCardPropsAtom @@ -535,14 +534,14 @@ const ActionMatcherAndProposer = ({ refreshProposals() // Close modal. - setMsgs(undefined) + close() }, [ deleteDraft, draftIndex, refreshProposals, - setMsgs, setProposalCreatedCardProps, + close, ] ) @@ -574,7 +573,7 @@ const ActionMatcherAndProposer = ({ title: t('title.createProposal'), subtitle: t('info.appsProposalDescription'), }} - onClose={() => setMsgs(undefined)} + onClose={close} visible > diff --git a/packages/stateful/components/dao/tabs/HomeTab.tsx b/packages/stateful/components/dao/tabs/HomeTab.tsx index 6f2d01143..58a605aa8 100644 --- a/packages/stateful/components/dao/tabs/HomeTab.tsx +++ b/packages/stateful/components/dao/tabs/HomeTab.tsx @@ -10,7 +10,10 @@ import { } from '@dao-dao/stateless' import { CheckedDepositInfo, DaoPageMode } from '@dao-dao/types' -import { useDaoWithWalletSecretNetworkPermit } from '../../../hooks' +import { + useDaoGovernanceToken, + useDaoWithWalletSecretNetworkPermit, +} from '../../../hooks' import { matchAndLoadCommon } from '../../../proposal-module-adapter' import { useVotingModuleAdapter } from '../../../voting-module-adapter' import { ButtonLink } from '../../ButtonLink' @@ -29,7 +32,6 @@ export const HomeTab = () => { const { components: { ProfileCardMemberInfo }, - hooks: { useCommonGovernanceTokenInfo }, } = useVotingModuleAdapter() const depositInfoSelectors = useMemo( @@ -45,7 +47,7 @@ export const HomeTab = () => { ) const { denomOrAddress: governanceDenomOrAddress } = - useCommonGovernanceTokenInfo?.() ?? {} + useDaoGovernanceToken() ?? {} // Get max deposit of governance token across all proposal modules. const maxGovernanceTokenProposalModuleDeposit = diff --git a/packages/stateful/components/dao/tabs/SubDaosTab.tsx b/packages/stateful/components/dao/tabs/SubDaosTab.tsx index 71bff50bd..e0c5edf51 100644 --- a/packages/stateful/components/dao/tabs/SubDaosTab.tsx +++ b/packages/stateful/components/dao/tabs/SubDaosTab.tsx @@ -4,11 +4,11 @@ import { SubDaosTab as StatelessSubDaosTab, useDaoInfoContext, useDaoNavHelpers, + useInitializedActionForKey, } from '@dao-dao/stateless' import { ActionKey, Feature } from '@dao-dao/types' import { getDaoProposalSinglePrefill } from '@dao-dao/utils' -import { useActionForKey } from '../../../actions' import { useMembership, useQueryLoadingDataWithError } from '../../../hooks' import { daoQueries } from '../../../queries' import { ButtonLink } from '../../ButtonLink' @@ -29,8 +29,7 @@ export const SubDaosTab = () => { enabled: !!supportedFeatures[Feature.SubDaos], }) - const upgradeToV2Action = useActionForKey(ActionKey.UpgradeV1ToV2) - const upgradeToV2ActionDefaults = upgradeToV2Action?.useDefaults() + const upgradeToV2Action = useInitializedActionForKey(ActionKey.UpgradeV1ToV2) return ( { createSubDaoHref={getDaoPath(coreAddress, 'create')} isMember={isMember} subDaos={subDaos} - upgradeToV2Href={getDaoProposalPath(coreAddress, 'create', { - prefill: getDaoProposalSinglePrefill({ - actions: upgradeToV2Action - ? [ - { - actionKey: upgradeToV2Action.key, - data: upgradeToV2ActionDefaults, - }, - ] - : [], - }), - })} + upgradeToV2Href={ + upgradeToV2Action.loading || upgradeToV2Action.errored + ? undefined + : getDaoProposalPath(coreAddress, 'create', { + prefill: getDaoProposalSinglePrefill({ + actions: [ + { + actionKey: upgradeToV2Action.data.key, + data: upgradeToV2Action.data.defaults, + }, + ], + }), + }) + } /> ) } diff --git a/packages/stateful/components/dao/tabs/TreasuryTab.tsx b/packages/stateful/components/dao/tabs/TreasuryTab.tsx index bc720d6bd..d449396b4 100644 --- a/packages/stateful/components/dao/tabs/TreasuryTab.tsx +++ b/packages/stateful/components/dao/tabs/TreasuryTab.tsx @@ -7,11 +7,11 @@ import { useCachedLoading, useDaoInfoContext, useDaoNavHelpers, + useInitializedActionForKey, } from '@dao-dao/stateless' import { ActionKey, LazyNftCardInfo, TokenCardInfo } from '@dao-dao/types' import { getDaoProposalSinglePrefill } from '@dao-dao/utils' -import { useActionForKey } from '../../../actions' import { useWallet } from '../../../hooks' import { useCw20CommonGovernanceTokenInfoIfExists, @@ -55,37 +55,36 @@ export const TreasuryTab = () => { {} ) - const createCrossChainAccountAction = useActionForKey( + const createCrossChainAccountAction = useInitializedActionForKey( ActionKey.CreateCrossChainAccount ) - const createCrossChainAccountActionDefaults = - createCrossChainAccountAction?.useDefaults() - const createCrossChainAccountPrefill = getDaoProposalSinglePrefill({ - actions: createCrossChainAccountAction - ? [ - { - actionKey: createCrossChainAccountAction.key, - data: createCrossChainAccountActionDefaults, - }, - ] - : [], - }) + const createCrossChainAccountPrefill = + createCrossChainAccountAction.loading || + createCrossChainAccountAction.errored + ? undefined + : getDaoProposalSinglePrefill({ + actions: [ + { + actionKey: createCrossChainAccountAction.data.key, + data: createCrossChainAccountAction.data.defaults, + }, + ], + }) - const configureRebalancerAction = useActionForKey( + const configureRebalancerAction = useInitializedActionForKey( ActionKey.ConfigureRebalancer ) - const configureRebalancerActionDefaults = - configureRebalancerAction?.useDefaults() - const configureRebalancerPrefill = getDaoProposalSinglePrefill({ - actions: configureRebalancerAction - ? [ - { - actionKey: ActionKey.ConfigureRebalancer, - data: configureRebalancerActionDefaults, - }, - ] - : [], - }) + const configureRebalancerPrefill = + configureRebalancerAction.loading || configureRebalancerAction.errored + ? undefined + : getDaoProposalSinglePrefill({ + actions: [ + { + actionKey: configureRebalancerAction.data.key, + data: configureRebalancerAction.data.defaults, + }, + ], + }) return ( @@ -107,8 +106,10 @@ export const TreasuryTab = () => { createCrossChainAccountHref={ // Only show create cross-chain account button if we can use the action // (i.e. chains are missing and can be created). - createCrossChainAccountAction && - !createCrossChainAccountAction.hideFromPicker + createCrossChainAccountPrefill && + !createCrossChainAccountAction.loading && + !createCrossChainAccountAction.errored && + !createCrossChainAccountAction.data.metadata.hideFromPicker ? getDaoProposalPath(daoInfo.coreAddress, 'create', { prefill: createCrossChainAccountPrefill, }) diff --git a/packages/stateful/components/gov/GovProposal.tsx b/packages/stateful/components/gov/GovProposal.tsx index 952b0ce0c..8d667072c 100644 --- a/packages/stateful/components/gov/GovProposal.tsx +++ b/packages/stateful/components/gov/GovProposal.tsx @@ -1,18 +1,14 @@ +import { useQueryClient } from '@tanstack/react-query' import { Dispatch, SetStateAction, useCallback, useRef } from 'react' import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' -import { useSetRecoilState } from 'recoil' -import { - govProposalSelector, - refreshGovProposalsAtom, -} from '@dao-dao/state/recoil' +import { chainQueries } from '@dao-dao/state/query' import { PageLoader, Popup, Proposal, ProposalNotFound, - useCachedLoadingWithError, useChain, useDaoInfoContextIfAvailable, } from '@dao-dao/stateless' @@ -28,6 +24,8 @@ import { GovActionsProvider } from '../../actions' import { useLoadingGovProposal, useOnCurrentDaoWebSocketMessage, + useQueryLoadingDataWithError, + useRefreshGovProposals, } from '../../hooks' import { DaoProposalProps } from '../dao/DaoPageWrapper' import { PageHeaderContent } from '../PageHeaderContent' @@ -63,22 +61,21 @@ const InnerGovProposal = ({ proposal }: InnerGovProposalProps) => { const alreadyVoted = !loadingProposal.loading && !loadingProposal.data.walletVoteInfo.loading && + !loadingProposal.data.walletVoteInfo.errored && !!loadingProposal.data.walletVoteInfo.data.vote?.length const setVoteOpenRef = useRef< (Dispatch> | null) | null >(null) - const setRefreshGovProposalsId = useSetRecoilState( - refreshGovProposalsAtom(chainId) - ) + const refreshGovProposals = useRefreshGovProposals() // Proposal status listener. Show alerts and refresh. useOnCurrentDaoWebSocketMessage( 'proposal', async ({ status, proposalId }) => { // If the current proposal updated... if (proposalId === proposal.id.toString()) { - setRefreshGovProposalsId((id) => id + 1) + refreshGovProposals() // Manually revalidate static props. fetch( @@ -172,9 +169,10 @@ const InnerGovProposal = ({ proposal }: InnerGovProposalProps) => { export const GovProposal = ({ proposalInfo }: DaoProposalProps) => { const { chain_id: chainId } = useChain() - const proposalLoading = useCachedLoadingWithError( + const queryClient = useQueryClient() + const proposalLoading = useQueryLoadingDataWithError( proposalInfo - ? govProposalSelector({ + ? chainQueries.govProposal(queryClient, { chainId, proposalId: Number(proposalInfo.id), }) diff --git a/packages/stateful/components/gov/GovProposalActionDisplay.tsx b/packages/stateful/components/gov/GovProposalActionDisplay.tsx index 9a46efb62..fc03fd4f3 100644 --- a/packages/stateful/components/gov/GovProposalActionDisplay.tsx +++ b/packages/stateful/components/gov/GovProposalActionDisplay.tsx @@ -1,28 +1,23 @@ import { DataObject } from '@mui/icons-material' -import { useMemo, useState } from 'react' +import { useState } from 'react' import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' import { - ActionsRenderer, + ActionsMatchAndRender, Button, CosmosMessageDisplay, Loader, + RawActionsRenderer, } from '@dao-dao/stateless' import { - ActionAndData, GovProposalActionDisplayProps, GovProposalVersion, } from '@dao-dao/types' import { CommunityPoolSpendProposal } from '@dao-dao/types/protobuf/codegen/cosmos/distribution/v1beta1/distribution' import { TextProposal } from '@dao-dao/types/protobuf/codegen/cosmos/gov/v1beta1/gov' -import { - decodeMessages, - decodeRawDataForDisplay, - objectMatchesStructure, -} from '@dao-dao/utils' +import { decodeRawDataForDisplay, objectMatchesStructure } from '@dao-dao/utils' -import { useActionsForMatching } from '../../actions' import { PayEntityDisplay } from '../PayEntityDisplay' import { SuspenseLoader } from '../SuspenseLoader' @@ -36,14 +31,7 @@ export const GovProposalActionDisplay = ( props: GovProposalActionDisplayProps ) => ( }> - + ) @@ -53,47 +41,8 @@ const InnerGovProposalActionDisplay = ({ }: GovProposalActionDisplayProps) => { const { t } = useTranslation() - const actionsForMatching = useActionsForMatching() - const [showRaw, setShowRaw] = useState(false) - const { decodedMessages, rawDecodedMessages } = useMemo(() => { - const decodedMessages = - content.version === GovProposalVersion.V1 - ? decodeMessages(content.decodedMessages) - : [] - - const rawDecodedMessages = JSON.stringify( - decodedMessages.map(decodeRawDataForDisplay), - null, - 2 - ) - - return { - decodedMessages, - rawDecodedMessages, - } - }, [content]) - - // Call relevant action hooks in the same order every time. - const actionData = decodedMessages - .map((message) => { - const actionMatch = actionsForMatching - .map((action) => ({ - action, - ...action.useDecodedCosmosMsg(message), - })) - .find(({ match }) => match) - - return ( - actionMatch && { - action: actionMatch.action, - data: actionMatch.data, - } - ) - }) - .filter(Boolean) as ActionAndData[] - const decodedContent = content.version === GovProposalVersion.V1_BETA_1 ? content.decodedContent @@ -154,10 +103,10 @@ const InnerGovProposalActionDisplay = ({ {content.version === GovProposalVersion.V1 && content.decodedMessages?.length ? (
- toast.success(t('info.copiedLinkToClipboard'))} /> @@ -168,7 +117,15 @@ const InnerGovProposalActionDisplay = ({

- {showRaw && } + {showRaw && ( + + )}
) : null} diff --git a/packages/stateful/components/gov/GovProposalContentDisplay.tsx b/packages/stateful/components/gov/GovProposalContentDisplay.tsx index b5be57fc9..602df68f9 100644 --- a/packages/stateful/components/gov/GovProposalContentDisplay.tsx +++ b/packages/stateful/components/gov/GovProposalContentDisplay.tsx @@ -1,14 +1,15 @@ -import { useSetRecoilState } from 'recoil' - -import { refreshGovProposalsAtom } from '@dao-dao/state/recoil' -import { ProposalContentDisplay, useChain } from '@dao-dao/stateless' +import { ProposalContentDisplay } from '@dao-dao/stateless' import { GovProposalVersion, GovProposalWithDecodedContent, } from '@dao-dao/types' import { govProposalToDecodedContent } from '@dao-dao/utils' -import { useEntity, useLoadingGovProposal } from '../../hooks' +import { + useEntity, + useLoadingGovProposal, + useRefreshGovProposals, +} from '../../hooks' import { EntityDisplay } from '../EntityDisplay' import { IconButtonLink } from '../IconButtonLink' import { GovProposalActionDisplay } from './GovProposalActionDisplay' @@ -20,8 +21,6 @@ export type GovProposalContentDisplayProps = { export const GovProposalContentDisplay = ({ proposal, }: GovProposalContentDisplayProps) => { - const { chain_id: chainId } = useChain() - const proposerAddress = (proposal.version === GovProposalVersion.V1 && proposal.proposal.proposer) || @@ -29,7 +28,7 @@ export const GovProposalContentDisplay = ({ const { entity } = useEntity(proposerAddress) const loadingProposal = useLoadingGovProposal(proposal.id.toString()) - const setRefreshProposal = useSetRecoilState(refreshGovProposalsAtom(chainId)) + const refreshProposal = useRefreshGovProposals() return ( } - onRefresh={() => setRefreshProposal((id) => id + 1)} + onRefresh={refreshProposal} refreshing={loadingProposal.loading || !!loadingProposal.updating} title={proposal.title} /> diff --git a/packages/stateful/components/gov/GovProposalLine.tsx b/packages/stateful/components/gov/GovProposalLine.tsx index 8c4cf7ebd..c79bbef33 100644 --- a/packages/stateful/components/gov/GovProposalLine.tsx +++ b/packages/stateful/components/gov/GovProposalLine.tsx @@ -48,7 +48,8 @@ const InnerGovProposalLine = (props: StatefulGovProposalLineProps) => { title={proposal.title} vote={ // If loading, show nothing until loaded. - loadingWalletVoteInfo.loading ? undefined : ( + loadingWalletVoteInfo.loading || + loadingWalletVoteInfo.errored ? undefined : ( { +export const GovProposalList = ({ className }: { className: string }) => { const { t } = useTranslation() const chain = useChain() const { asPath } = useRouter() + const queryClient = useQueryClient() const hasIndexer = chainIsIndexed(chain.chain_id) // Refresh all proposals on proposal WebSocket messages. - const setRefreshGovProposalsId = useSetRecoilState( - refreshGovProposalsAtom(chain.chain_id) - ) - useOnCurrentDaoWebSocketMessage('proposal', () => - setRefreshGovProposalsId((id) => id + 1) - ) + const refreshGovProposals = useRefreshGovProposals() + useOnCurrentDaoWebSocketMessage('proposal', refreshGovProposals) - const openGovProposalsVotingPeriod = useCachedLoading( - govProposalsSelector({ + const openGovProposalsVotingPeriod = useQueryLoadingDataWithError( + chainQueries.govProposals(queryClient, { chainId: chain.chain_id, status: ProposalStatus.PROPOSAL_STATUS_VOTING_PERIOD, - }), - { - proposals: [], - total: 0, - } + }) ) - const govProposalsDepositPeriod = useCachedLoading( - govProposalsSelector({ + const govProposalsDepositPeriod = useQueryLoadingDataWithError( + chainQueries.govProposals(queryClient, { chainId: chain.chain_id, status: ProposalStatus.PROPOSAL_STATUS_DEPOSIT_PERIOD, - }), - { - proposals: [], - total: 0, - } + }) ) // Get max page by loading a single item and then getting the total. - const loadingMaxPage = useCachedLoading( - govProposalsSelector({ + const loadingMaxPage = useQueryLoadingDataWithError( + chainQueries.govProposals(queryClient, { chainId: chain.chain_id, limit: 1, - }), - { - proposals: [], - total: 0, - } + }) ) const maxPage = - loadingMaxPage.loading || loadingMaxPage.updating + loadingMaxPage.loading || loadingMaxPage.errored || loadingMaxPage.updating ? 1 : Math.ceil(loadingMaxPage.data.total / PROPSALS_PER_PAGE) const [page, setPage] = useState(1) - const loadingAllGovProposals = useCachedLoading( - govProposalsSelector({ + const loadingPaginatedGovProposals = useQueryLoadingDataWithError( + chainQueries.govProposals(queryClient, { chainId: chain.chain_id, offset: (page - 1) * PROPSALS_PER_PAGE, limit: PROPSALS_PER_PAGE, - }), - { - proposals: [], - total: 0, - }, - (error) => { - console.error(error) - throw new Error(t('error.loadingData')) - } + }) ) // Only allow incrementing the page once the current page has loaded and if // we're below the max. const goToNextPage = useCallback(() => { - if (loadingAllGovProposals.loading || loadingAllGovProposals.updating) { + if ( + loadingPaginatedGovProposals.loading || + loadingPaginatedGovProposals.updating + ) { return } setPage((page) => Math.min(page + 1, maxPage)) - }, [loadingAllGovProposals, maxPage]) + }, [loadingPaginatedGovProposals, maxPage]) const [historyProposals, setHistoryProposals] = useState< StatefulGovProposalLineProps[] >([]) useEffect(() => { - if (loadingAllGovProposals.loading || loadingAllGovProposals.updating) { + if ( + loadingPaginatedGovProposals.loading || + loadingPaginatedGovProposals.errored || + loadingPaginatedGovProposals.updating + ) { return } - const newHistoryProposals = loadingAllGovProposals.data.proposals + const newHistoryProposals = loadingPaginatedGovProposals.data.proposals .filter( (prop) => prop.proposal.status === ProposalStatus.PROPOSAL_STATUS_PASSED || @@ -134,52 +116,57 @@ export const GovProposalList = () => { ), ].sort((a, b) => Number(b.proposalId) - Number(a.proposalId)) ) - }, [loadingAllGovProposals]) - - const openProposals = openGovProposalsVotingPeriod.loading - ? [] - : openGovProposalsVotingPeriod.data.proposals - .map( - (proposal): StatefulGovProposalLineProps => ({ - proposalId: proposal.id.toString(), - proposal, - }) - ) - .sort( - (a, b) => - (b.proposal.proposal.votingEndTime ?? new Date(0)).getTime() - - (a.proposal.proposal.votingEndTime ?? new Date(0)).getTime() - ) - - const depositPeriodProposals = govProposalsDepositPeriod.loading - ? [] - : govProposalsDepositPeriod.data.proposals - .map( - (proposal): StatefulGovProposalLineProps => ({ - proposalId: proposal.id.toString(), - proposal, - }) - ) - .sort( - (a, b) => - (b.proposal.proposal.depositEndTime ?? new Date(0)).getTime() - - (a.proposal.proposal.depositEndTime ?? new Date(0)).getTime() - ) + }, [loadingPaginatedGovProposals]) + + const openProposals = + openGovProposalsVotingPeriod.loading || openGovProposalsVotingPeriod.errored + ? [] + : openGovProposalsVotingPeriod.data.proposals + .map( + (proposal): StatefulGovProposalLineProps => ({ + proposalId: proposal.id.toString(), + proposal, + }) + ) + .sort( + (a, b) => + (b.proposal.proposal.votingEndTime ?? new Date(0)).getTime() - + (a.proposal.proposal.votingEndTime ?? new Date(0)).getTime() + ) + + const depositPeriodProposals = + govProposalsDepositPeriod.loading || govProposalsDepositPeriod.errored + ? [] + : govProposalsDepositPeriod.data.proposals + .map( + (proposal): StatefulGovProposalLineProps => ({ + proposalId: proposal.id.toString(), + proposal, + }) + ) + .sort( + (a, b) => + (b.proposal.proposal.depositEndTime ?? new Date(0)).getTime() - + (a.proposal.proposal.depositEndTime ?? new Date(0)).getTime() + ) const historyCount = - loadingAllGovProposals.loading || + loadingPaginatedGovProposals.loading || + loadingPaginatedGovProposals.errored || openGovProposalsVotingPeriod.loading || - govProposalsDepositPeriod.loading + openGovProposalsVotingPeriod.errored || + govProposalsDepositPeriod.loading || + govProposalsDepositPeriod.errored ? 0 - : loadingAllGovProposals.data.total - + : loadingPaginatedGovProposals.data.total - openGovProposalsVotingPeriod.data.proposals.length - govProposalsDepositPeriod.data.proposals.length const [search, setSearch] = useState('') const showingSearchResults = hasIndexer && !!search && search.length > 0 - const searchedGovProposals = useCachedLoadingWithError( + const searchedGovProposals = useQueryLoadingDataWithError( showingSearchResults - ? searchedDecodedGovProposalsSelector({ + ? chainQueries.searchAndDecodeGovProposals(queryClient, { chainId: chain.chain_id, query: search, limit: 20, @@ -193,9 +180,23 @@ export const GovProposalList = () => { LinkWrapper={LinkWrapper} ProposalLine={GovProposalLine} canLoadMore={!showingSearchResults && page < maxPage} + className={className} createNewProposalHref={asPath + '/create'} daoName={getDisplayNameForChainId(chain.chain_id)} daosWithVetoableProposals={[]} + error={ + showingSearchResults + ? searchedGovProposals.errored + ? searchedGovProposals.error + : undefined + : loadingPaginatedGovProposals.errored + ? loadingPaginatedGovProposals.error + : openGovProposalsVotingPeriod.errored + ? openGovProposalsVotingPeriod.error + : govProposalsDepositPeriod.errored + ? govProposalsDepositPeriod.error + : undefined + } isMember={true} loadMore={goToNextPage} loadingMore={ @@ -203,8 +204,8 @@ export const GovProposalList = () => { ? searchedGovProposals.loading || !!searchedGovProposals.updating : openGovProposalsVotingPeriod.loading || govProposalsDepositPeriod.loading || - loadingAllGovProposals.loading || - !!loadingAllGovProposals.updating + loadingPaginatedGovProposals.loading || + !!loadingPaginatedGovProposals.updating } openProposals={showingSearchResults ? [] : openProposals} searchBarProps={ diff --git a/packages/stateful/components/gov/GovProposalStatusAndInfo.tsx b/packages/stateful/components/gov/GovProposalStatusAndInfo.tsx index 75a064c41..24b458111 100644 --- a/packages/stateful/components/gov/GovProposalStatusAndInfo.tsx +++ b/packages/stateful/components/gov/GovProposalStatusAndInfo.tsx @@ -11,12 +11,8 @@ import clsx from 'clsx' import { ComponentProps, ComponentType, useCallback, useState } from 'react' import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' -import { useSetRecoilState } from 'recoil' -import { - genericTokenSelector, - refreshGovProposalsAtom, -} from '@dao-dao/state/recoil' +import { genericTokenSelector } from '@dao-dao/state/recoil' import { GOV_PROPOSAL_STATUS_I18N_KEY_MAP, Logo, @@ -46,7 +42,11 @@ import { processError, } from '@dao-dao/utils' -import { useLoadingGovProposal, useWallet } from '../../hooks' +import { + useLoadingGovProposal, + useRefreshGovProposals, + useWallet, +} from '../../hooks' import { ButtonLink } from '../ButtonLink' import { EntityDisplay } from '../EntityDisplay' import { SuspenseLoader } from '../SuspenseLoader' @@ -217,11 +217,7 @@ const InnerGovProposalStatusAndInfo = ({ turnoutYesPercent: formatPercentOf100(turnoutYesPercent), }) - const setRefreshProposal = useSetRecoilState(refreshGovProposalsAtom(chainId)) - const refreshProposal = useCallback( - () => setRefreshProposal((id) => id + 1), - [setRefreshProposal] - ) + const refreshProposal = useRefreshGovProposals() const [depositValue, setDepositValue] = useState(missingDeposit) const [depositing, setDepositing] = useState(false) diff --git a/packages/stateful/components/gov/GovProposalVoter.tsx b/packages/stateful/components/gov/GovProposalVoter.tsx index 92ca481c6..bf6761b02 100644 --- a/packages/stateful/components/gov/GovProposalVoter.tsx +++ b/packages/stateful/components/gov/GovProposalVoter.tsx @@ -2,13 +2,10 @@ import { EncodeObject } from '@cosmjs/proto-signing' import { useCallback, useState } from 'react' import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' -import { useSetRecoilState } from 'recoil' -import { refreshGovProposalsAtom } from '@dao-dao/state/recoil' import { Loader, ProposalVoter as StatelessProposalVoter, - useChain, useGovProposalVoteOptions, useUpdatingRef, } from '@dao-dao/stateless' @@ -20,7 +17,11 @@ import { import { MsgVote } from '@dao-dao/types/protobuf/codegen/cosmos/gov/v1beta1/tx' import { CHAIN_GAS_MULTIPLIER, processError } from '@dao-dao/utils' -import { useLoadingGovProposal, useWallet } from '../../hooks' +import { + useLoadingGovProposal, + useRefreshGovProposals, + useWallet, +} from '../../hooks' import { SuspenseLoader } from '../SuspenseLoader' export type GovProposalVoterProps = { @@ -58,18 +59,13 @@ const InnerGovProposalVoter = ({ proposal: GovProposalWithMetadata }) => { const { t } = useTranslation() - const { chain_id: chainId } = useChain() const { isWalletConnected, address: walletAddress = '', getSigningStargateClient, } = useWallet() - const setRefreshProposal = useSetRecoilState(refreshGovProposalsAtom(chainId)) - const refreshProposal = useCallback( - () => setRefreshProposal((id) => id + 1), - [setRefreshProposal] - ) + const refreshProposal = useRefreshGovProposals() const onVoteSuccessRef = useUpdatingRef(onVoteSuccess) @@ -133,7 +129,7 @@ const InnerGovProposalVoter = ({ { const { chain_id: chainId } = useChain() + const queryClient = useQueryClient() // Load all staked voting power. const { bondedTokens } = useRecoilValue( @@ -51,41 +51,36 @@ const InnerGovProposalVotes = ({ const [loading, setLoading] = useState(true) const [noMoreVotes, setNoMoreVotes] = useState(false) const [votes, setVotes] = useState([]) - const loadVotes = useRecoilCallback( - ({ snapshot }) => - async () => { - setLoading(true) - try { - const newVotes = ( - await snapshot.getPromise( - govProposalVotesSelector({ - chainId, - proposalId: Number(proposalId), - offset: votes.length, - limit: VOTES_PER_PAGE, - }) - ) - ).votes.map( - ({ voter, options, staked }): ProposalVote => ({ - voterAddress: voter, - vote: options.sort( - (a, b) => Number(b.weight) - Number(a.weight) - )[0].option, - votingPowerPercent: - Number(staked) / Number(BigInt(bondedTokens) / 100n), - }) - ) + const loadVotes = useCallback(async () => { + setLoading(true) + try { + const newVotes = ( + await queryClient.fetchQuery( + chainQueries.govProposalVotes(queryClient, { + chainId, + proposalId: Number(proposalId), + offset: votes.length, + limit: VOTES_PER_PAGE, + }) + ) + ).votes.map( + ({ voter, options, staked }): ProposalVote => ({ + voterAddress: voter, + vote: options.sort((a, b) => Number(b.weight) - Number(a.weight))[0] + .option, + votingPowerPercent: + Number(staked) / Number(BigInt(bondedTokens) / 100n), + }) + ) - setVotes((prev) => - uniqBy([...prev, ...newVotes], ({ voterAddress }) => voterAddress) - ) - setNoMoreVotes(newVotes.length < VOTES_PER_PAGE) - } finally { - setLoading(false) - } - }, - [chainId, proposalId, votes.length, bondedTokens] - ) + setVotes((prev) => + uniqBy([...prev, ...newVotes], ({ voterAddress }) => voterAddress) + ) + setNoMoreVotes(newVotes.length < VOTES_PER_PAGE) + } finally { + setLoading(false) + } + }, [queryClient, chainId, proposalId, votes.length, bondedTokens]) // Load once. useEffect(() => { loadVotes() diff --git a/packages/stateful/components/gov/NewGovProposal.tsx b/packages/stateful/components/gov/NewGovProposal.tsx index d87c2152a..26ad47c60 100644 --- a/packages/stateful/components/gov/NewGovProposal.tsx +++ b/packages/stateful/components/gov/NewGovProposal.tsx @@ -19,6 +19,7 @@ import { MutableRefObject, useCallback, useEffect, + useMemo, useRef, useState, } from 'react' @@ -30,20 +31,13 @@ import { } from 'react-hook-form' import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' -import { - useRecoilCallback, - useRecoilState, - useRecoilValue, - useSetRecoilState, -} from 'recoil' +import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil' +import { accountQueries, chainQueries } from '@dao-dao/state/query' import { - accountsSelector, - govProposalSelector, latestProposalSaveAtom, proposalCreatedCardPropsAtom, proposalDraftsAtom, - refreshGovProposalsAtom, } from '@dao-dao/state/recoil' import { makeGetSignerOptions } from '@dao-dao/state/utils' import { @@ -55,17 +49,16 @@ import { PageLoader, ProposalContentDisplay, Tooltip, - useCachedLoadingWithError, + useActionOptions, useConfiguredChainContext, useDaoNavHelpers, useHoldingKey, + useLoadingPromise, useUpdatingRef, } from '@dao-dao/stateless' import { - Action, ActionChainContextType, ActionContextType, - GovProposalVersion, GovernanceProposalActionData, ProposalDraft, } from '@dao-dao/types' @@ -95,9 +88,14 @@ import { uploadJsonToIpfs, } from '@dao-dao/utils' -import { WalletActionsProvider, useActionOptions } from '../../actions' -import { makeGovernanceProposalAction } from '../../actions/core/chain_governance/GovernanceProposal' -import { useEntity, useProfile } from '../../hooks' +import { WalletActionsProvider } from '../../actions' +import { GovernanceProposalAction } from '../../actions/core/actions' +import { + useEntity, + useProfile, + useQueryLoadingDataWithError, + useRefreshGovProposals, +} from '../../hooks' import { useWallet } from '../../hooks/useWallet' import { EntityDisplay } from '../EntityDisplay' import { GovProposalActionDisplay } from './GovProposalActionDisplay' @@ -116,35 +114,45 @@ export const NewGovProposal = (innerProps: NewGovProposalProps) => { const { t } = useTranslation() const router = useRouter() const chainContext = useConfiguredChainContext() + const queryClient = useQueryClient() const { address: walletAddress = '' } = useWallet() const { profile } = useProfile() - const accounts = useCachedLoadingWithError( + const accounts = useQueryLoadingDataWithError( walletAddress - ? accountsSelector({ + ? accountQueries.list(queryClient, { chainId: chainContext.chainId, address: walletAddress, }) : undefined ) - const governanceProposalAction = makeGovernanceProposalAction({ - t, - chain: chainContext.chain, - chainContext: { - type: ActionChainContextType.Configured, - ...chainContext, - }, - address: walletAddress, - context: { - type: ActionContextType.Wallet, - profile: profile.loading - ? makeEmptyUnifiedProfile(chainContext.chainId, walletAddress) - : profile.data, - accounts: accounts.loading || accounts.errored ? [] : accounts.data, - }, - })! - const defaults = governanceProposalAction.useDefaults() + const governanceProposalAction = useMemo( + () => + new GovernanceProposalAction({ + t, + chain: chainContext.chain, + chainContext: { + type: ActionChainContextType.Configured, + ...chainContext, + }, + address: walletAddress, + context: { + type: ActionContextType.Wallet, + profile: profile.loading + ? makeEmptyUnifiedProfile(chainContext.chainId, walletAddress) + : profile.data, + accounts: accounts.loading || accounts.errored ? [] : accounts.data, + }, + queryClient, + }), + [t, chainContext, walletAddress, profile, accounts, queryClient] + ) + // Initialize governance proposal action and re-render when done. + useLoadingPromise({ + promise: () => governanceProposalAction.init(), + deps: [governanceProposalAction], + }) const localStorageKey = `gov_${chainContext.chainId}` const latestProposalSave = useRecoilValue( @@ -222,21 +230,23 @@ export const NewGovProposal = (innerProps: NewGovProposalProps) => { loadFromPrefill() }, [router.query.prefill, router.query.pi, router.isReady, prefillChecked, t]) - return !defaults || (walletAddress && accounts.loading) || !prefillChecked ? ( + return governanceProposalAction.loading || + (walletAddress && accounts.loading) || + !prefillChecked ? ( - ) : defaults instanceof Error ? ( - + ) : governanceProposalAction.errored ? ( + ) : ( ) } @@ -258,7 +268,7 @@ type InnerNewGovProposalProps = { /** * The governance proposal creation action. */ - action: Action + action: GovernanceProposalAction /** * A function ref that the parent uses to clear the form. */ @@ -311,11 +321,9 @@ const InnerNewGovProposal = ({ latestProposalSaveAtom(localStorageKey) ) - const transformGovernanceProposalActionDataToCosmos = - action.useTransformToCosmos() const formMethods = useForm({ mode: 'onChange', - defaultValues: defaults, + defaultValues: cloneDeep(defaults), }) const { handleSubmit, @@ -332,217 +340,199 @@ const InnerNewGovProposal = ({ setSubmitError('') }, [setShowSubmitErrorNote]) - const setRefreshGovProposals = useSetRecoilState( - refreshGovProposalsAtom(chainContext.chainId) - ) - const refreshGovProposals = useCallback( - () => setRefreshGovProposals((id) => id + 1), - [setRefreshGovProposals] - ) + const refreshGovProposals = useRefreshGovProposals() - const onSubmitForm: SubmitHandler = - useRecoilCallback( - ({ snapshot }) => - async (data, event) => { - const nativeEvent = event?.nativeEvent as SubmitEvent - const submitterValue = (nativeEvent?.submitter as HTMLInputElement) - ?.value + const onSubmitForm: SubmitHandler = useCallback( + async (data, event) => { + const nativeEvent = event?.nativeEvent as SubmitEvent + const submitterValue = (nativeEvent?.submitter as HTMLInputElement)?.value - setShowSubmitErrorNote(false) + setShowSubmitErrorNote(false) - // Preview toggled in onClick handler. - if (submitterValue === ProposeSubmitValue.Preview) { - return - } + // Preview toggled in onClick handler. + if (submitterValue === ProposeSubmitValue.Preview) { + return + } - if (!isWalletConnected || !chainWallet) { - toast.error(t('error.logInToContinue')) - return - } + if (!isWalletConnected || !chainWallet) { + toast.error(t('error.logInToContinue')) + return + } - setSubmitError('') + setSubmitError('') - let encodeObject: EncodeObject - try { - // Transforms to stargate Cosmos message that submits proposal. - const submitProposalStargateMsg = - transformGovernanceProposalActionDataToCosmos(data) - if ( - !submitProposalStargateMsg || - !isCosmWasmStargateMsg(submitProposalStargateMsg) - ) { - throw new Error(t('error.loadingData')) - } + let encodeObject: EncodeObject + try { + // Transforms to stargate Cosmos message that submits proposal. + const submitProposalStargateMsg = await action.encode(data) + if ( + !submitProposalStargateMsg || + !isCosmWasmStargateMsg(submitProposalStargateMsg) + ) { + throw new Error(t('error.loadingData')) + } - // Transform Cosmos message to executable encode object. - encodeObject = - data.version === GovProposalVersion.V1_BETA_1 - ? { - typeUrl: MsgSubmitProposalV1Beta1.typeUrl, - value: MsgSubmitProposalV1Beta1.decode( - fromBase64(submitProposalStargateMsg.stargate.value) - ), - } - : { - typeUrl: MsgSubmitProposalV1.typeUrl, - value: MsgSubmitProposalV1.decode( - fromBase64(submitProposalStargateMsg.stargate.value) - ), - } - - // Need to re-encode the v1beta1 content as Any since `decode` fully - // decodes the content into an object, but we need it encoded. - if (encodeObject.typeUrl === MsgSubmitProposalV1Beta1.typeUrl) { - const reader = new BinaryReader( - fromBase64(submitProposalStargateMsg.stargate.value) - ) - const end = reader.len - while (reader.pos < end) { - const tag = reader.uint32() - switch (tag >>> 3) { - case 1: - ;(encodeObject.value as MsgSubmitProposalV1Beta1).content = - Any.decode(reader, reader.uint32()) as any - break - default: - reader.skipType(tag & 7) - break - } - } + // Transform Cosmos message to executable encode object. + encodeObject = { + typeUrl: submitProposalStargateMsg.stargate.type_url, + value: + submitProposalStargateMsg.stargate.type_url === + MsgSubmitProposalV1.typeUrl + ? MsgSubmitProposalV1.decode( + fromBase64(submitProposalStargateMsg.stargate.value) + ) + : MsgSubmitProposalV1Beta1.decode( + fromBase64(submitProposalStargateMsg.stargate.value) + ), + } + + // Need to re-encode the v1beta1 content as Any since `decode` fully + // decodes the content into an object, but we need it encoded. + if (encodeObject.typeUrl === MsgSubmitProposalV1Beta1.typeUrl) { + const reader = new BinaryReader( + fromBase64(submitProposalStargateMsg.stargate.value) + ) + const end = reader.len + while (reader.pos < end) { + const tag = reader.uint32() + switch (tag >>> 3) { + case 1: + ;(encodeObject.value as MsgSubmitProposalV1Beta1).content = + Any.decode(reader, reader.uint32()) as any + break + default: + reader.skipType(tag & 7) + break } - } catch (err) { - console.error(err) - setSubmitError( - processError(err, { - forceCapture: false, - }) - ) - return } + } + } catch (err) { + console.error(err) + setSubmitError( + processError(err, { + forceCapture: false, + }) + ) + return + } - setLoading(true) - try { - const signer = holdingAltForDirectSign - ? getOfflineSignerDirect() - : getOfflineSignerAmino() - const signingClient = await SigningStargateClient.connectWithSigner( - getRpcForChainId(chain.chain_id), - signer, - makeGetSignerOptions(queryClient)(chain) - ) + setLoading(true) + try { + const signer = holdingAltForDirectSign + ? getOfflineSignerDirect() + : getOfflineSignerAmino() + const signingClient = await SigningStargateClient.connectWithSigner( + getRpcForChainId(chain.chain_id), + signer, + makeGetSignerOptions(queryClient)(chain) + ) - const { events } = await signingClient.signAndBroadcast( - walletAddress, - [encodeObject], - CHAIN_GAS_MULTIPLIER - ) + const { events } = await signingClient.signAndBroadcast( + walletAddress, + [encodeObject], + CHAIN_GAS_MULTIPLIER + ) - const proposalId = Number( - events - .find( - ({ type, attributes }) => - type === 'submit_proposal' && - attributes.some(({ key }) => key === 'proposal_id') - ) - ?.attributes.find(({ key }) => key === 'proposal_id')?.value ?? - -1 + const proposalId = Number( + events + .find( + ({ type, attributes }) => + type === 'submit_proposal' && + attributes.some(({ key }) => key === 'proposal_id') ) - if (proposalId === -1) { - throw new Error(t('error.loadingData')) - } + ?.attributes.find(({ key }) => key === 'proposal_id')?.value ?? -1 + ) + if (proposalId === -1) { + throw new Error(t('error.loadingData')) + } - const proposal = await snapshot.getPromise( - govProposalSelector({ - chainId: chainContext.chainId, - proposalId, - }) - ) - if (!proposal) { - throw new Error(t('error.loadingData')) - } + const proposal = await queryClient.fetchQuery( + chainQueries.govProposal(queryClient, { + chainId: chainContext.chainId, + proposalId, + }) + ) - const endTime = - proposal.proposal.status === - ProposalStatus.PROPOSAL_STATUS_DEPOSIT_PERIOD - ? proposal.proposal.depositEndTime - : proposal.proposal.votingEndTime - - // Show modal. - setProposalCreatedCardProps({ - id: proposal.id.toString(), - title: proposal.title, - description: proposal.description, - info: [ - { - Icon: BookOutlined, - label: `${t('title.threshold')}: ${formatPercentOf100( - context.params.threshold * 100 - )}`, - }, - { - Icon: FlagOutlined, - label: `${t('title.quorum')}: ${formatPercentOf100( - context.params.quorum * 100 - )}`, - }, - ...(endTime - ? [ - { - Icon: Timelapse, - label: dateToWdhms(endTime), - }, - ] - : []), - ], - dao: { - name: getDisplayNameForChainId(chainContext.chainId), - coreAddress: chainContext.config.name, - imageUrl: getImageUrlForChainId(chainContext.chainId), - }, - }) - - // Refresh proposals state. - refreshGovProposals() - - // Clear saved form data. - setLatestProposalSave({}) - - // Navigate to proposal (underneath the creation modal). - router.push( - getDaoProposalPath( - chainContext.config.name, - proposalId.toString() - ) - ) + const endTime = + proposal.proposal.status === + ProposalStatus.PROPOSAL_STATUS_DEPOSIT_PERIOD + ? proposal.proposal.depositEndTime + : proposal.proposal.votingEndTime + + // Show modal. + setProposalCreatedCardProps({ + id: proposal.id.toString(), + title: proposal.title, + description: proposal.description, + info: [ + { + Icon: BookOutlined, + label: `${t('title.threshold')}: ${formatPercentOf100( + context.params.threshold * 100 + )}`, + }, + { + Icon: FlagOutlined, + label: `${t('title.quorum')}: ${formatPercentOf100( + context.params.quorum * 100 + )}`, + }, + ...(endTime + ? [ + { + Icon: Timelapse, + label: dateToWdhms(endTime), + }, + ] + : []), + ], + dao: { + name: getDisplayNameForChainId(chainContext.chainId), + coreAddress: chainContext.config.name, + imageUrl: getImageUrlForChainId(chainContext.chainId), + }, + }) - // Don't stop loading indicator on success since we are navigating. - } catch (err) { - console.error(err) - toast.error(processError(err)) - setLoading(false) - } - }, - [ - isWalletConnected, - chain, - chainWallet, - t, - transformGovernanceProposalActionDataToCosmos, - walletAddress, - getOfflineSignerAmino, - getOfflineSignerDirect, - holdingAltForDirectSign, - chainContext.chainId, - chainContext.config.name, - setProposalCreatedCardProps, - context.params.threshold, - context.params.quorum, - refreshGovProposals, - setLatestProposalSave, - router, - queryClient, - ] - ) + // Refresh proposals state. + refreshGovProposals() + + // Clear saved form data. + setLatestProposalSave({}) + + // Navigate to proposal (underneath the creation modal). + router.push( + getDaoProposalPath(chainContext.config.name, proposalId.toString()) + ) + + // Don't stop loading indicator on success since we are navigating. + } catch (err) { + console.error(err) + toast.error(processError(err)) + setLoading(false) + } + }, + [ + isWalletConnected, + chain, + chainWallet, + t, + action, + walletAddress, + getOfflineSignerAmino, + getOfflineSignerDirect, + holdingAltForDirectSign, + chainContext.chainId, + chainContext.config.name, + setProposalCreatedCardProps, + context.params.threshold, + context.params.quorum, + refreshGovProposals, + setLatestProposalSave, + router, + queryClient, + getDaoProposalPath, + ] + ) const proposalData = watch() // Reset form to defaults and clear latest proposal save. diff --git a/packages/stateful/components/nft/InstantiateNftCollectionAction.stories.tsx b/packages/stateful/components/nft/CreateNftCollectionAction.stories.tsx similarity index 53% rename from packages/stateful/components/nft/InstantiateNftCollectionAction.stories.tsx rename to packages/stateful/components/nft/CreateNftCollectionAction.stories.tsx index 8952e2877..8a6c74afb 100644 --- a/packages/stateful/components/nft/InstantiateNftCollectionAction.stories.tsx +++ b/packages/stateful/components/nft/CreateNftCollectionAction.stories.tsx @@ -8,29 +8,27 @@ import { } from '@dao-dao/storybook' import { - InstantiateNftCollectionAction, - InstantiateNftCollectionData, -} from './InstantiateNftCollectionAction' + CreateNftCollectionAction, + CreateNftCollectionActionData, +} from './CreateNftCollectionAction' export default { title: - 'DAO DAO / packages / stateful / components / nft / InstantiateNftCollectionAction', - component: InstantiateNftCollectionAction, + 'DAO DAO / packages / stateful / components / nft / CreateNftCollectionAction', + component: CreateNftCollectionAction, decorators: [ - makeReactHookFormDecorator({ + makeReactHookFormDecorator({ chainId: CHAIN_ID, name: '', symbol: '', }), makeDaoProvidersDecorator(makeDaoInfo()), ], -} as ComponentMeta +} as ComponentMeta -const Template: ComponentStory = ( - args -) => ( +const Template: ComponentStory = (args) => (
- +
) diff --git a/packages/stateful/components/nft/InstantiateNftCollectionAction.tsx b/packages/stateful/components/nft/CreateNftCollectionAction.tsx similarity index 86% rename from packages/stateful/components/nft/InstantiateNftCollectionAction.tsx rename to packages/stateful/components/nft/CreateNftCollectionAction.tsx index 609eb7103..890a892ef 100644 --- a/packages/stateful/components/nft/InstantiateNftCollectionAction.tsx +++ b/packages/stateful/components/nft/CreateNftCollectionAction.tsx @@ -5,20 +5,20 @@ import { InputErrorMessage, InputLabel, TextInput } from '@dao-dao/stateless' import { ActionComponent, ChainId } from '@dao-dao/types' import { validateRequired } from '@dao-dao/utils' -export type InstantiateNftCollectionData = { +export type CreateNftCollectionActionData = { chainId: string name: string symbol: string } -// Form displayed when the user is instantiating a new NFT collection. -export const InstantiateNftCollectionAction: ActionComponent = ({ +// Form displayed when the user is creating a new NFT collection. +export const CreateNftCollectionAction: ActionComponent = ({ isCreating, fieldNamePrefix, errors, }) => { const { t } = useTranslation() - const { register, watch } = useFormContext() + const { register, watch } = useFormContext() const chainId = watch((fieldNamePrefix + 'chainId') as 'chainId') diff --git a/packages/stateful/components/nft/index.ts b/packages/stateful/components/nft/index.ts index 64ff268e3..567264fa3 100644 --- a/packages/stateful/components/nft/index.ts +++ b/packages/stateful/components/nft/index.ts @@ -1,4 +1,4 @@ -export * from './InstantiateNftCollectionAction' +export * from './CreateNftCollectionAction' export * from './LazyNftCard' export * from './NftCard' export * from './NftSelectionModal' diff --git a/packages/stateful/components/pages/ProfileHome.tsx b/packages/stateful/components/pages/ProfileHome.tsx index f22ecaccf..750b10d8b 100644 --- a/packages/stateful/components/pages/ProfileHome.tsx +++ b/packages/stateful/components/pages/ProfileHome.tsx @@ -24,7 +24,7 @@ import { import { AccountTab, AccountTabId, Theme } from '@dao-dao/types' import { getConfiguredChainConfig, getConfiguredChains } from '@dao-dao/utils' -import { WalletActionsProvider } from '../../actions/react/provider' +import { WalletActionsProvider } from '../../actions/providers/wallet' import { useManageProfile } from '../../hooks' import { useWallet } from '../../hooks/useWallet' import { ProfileActions, ProfileDaos, ProfileWallet } from '../profile' diff --git a/packages/stateful/components/profile/ProfileActions.tsx b/packages/stateful/components/profile/ProfileActions.tsx index f4bde3555..1939c3b46 100644 --- a/packages/stateful/components/profile/ProfileActions.tsx +++ b/packages/stateful/components/profile/ProfileActions.tsx @@ -16,7 +16,6 @@ import { savedTxsSelector, temporarySavedTxsAtom, } from '@dao-dao/state' -import { useLoadedActionsAndCategories } from '@dao-dao/stateful/actions' import { ProfileActionsProps, ProfileActions as StatelessProfileActions, @@ -38,6 +37,7 @@ import { processError, } from '@dao-dao/utils' +import { useActionEncodeContext } from '../../actions' import { useCfWorkerAuthPostRequest, useWallet } from '../../hooks' import { SuspenseLoader } from '../SuspenseLoader' import { WalletChainSwitcher } from '../wallet' @@ -56,8 +56,6 @@ export const ProfileActions = () => { loadAccount: true, }) - const { loadedActions, categories } = useLoadedActionsAndCategories() - const [_meTransactionAtom, setWalletTransactionAtom] = useRecoilState( meTransactionAtom(chain.chain_id) ) @@ -123,7 +121,6 @@ export const ProfileActions = () => { const holdingAltForDirectSign = useHoldingKey({ key: 'alt' }) - const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [txHash, setTxHash] = useState('') const execute: ProfileActionsProps['execute'] = useCallback( @@ -133,7 +130,6 @@ export const ProfileActions = () => { return } - setLoading(true) setError('') setTxHash('') @@ -164,8 +160,6 @@ export const ProfileActions = () => { console.error(err) const error = processError(err) setError(error) - } finally { - setLoading(false) } }, [ @@ -273,18 +267,18 @@ export const ProfileActions = () => { return false } + const actionEncodeContext = useActionEncodeContext() + return ( { const { components: { ProfileCardMemberInfo }, - hooks: { useCommonGovernanceTokenInfo }, } = useVotingModuleAdapter() const depositInfoSelectors = useMemo( @@ -58,7 +58,7 @@ export const ProfileProposalCard = () => { ) const { denomOrAddress: governanceDenomOrAddress } = - useCommonGovernanceTokenInfo?.() ?? {} + useDaoGovernanceToken() ?? {} // Get max deposit of governance token across all proposal modules. const maxGovernanceTokenProposalModuleDeposit = diff --git a/packages/stateful/components/profile/ProfileWallet.tsx b/packages/stateful/components/profile/ProfileWallet.tsx index fde6d9e9b..898e77ff2 100644 --- a/packages/stateful/components/profile/ProfileWallet.tsx +++ b/packages/stateful/components/profile/ProfileWallet.tsx @@ -8,11 +8,11 @@ import { import { ProfileWallet as StatelessProfileWallet, useCachedLoadingWithError, + useInitializedActionForKey, } from '@dao-dao/stateless' import { ActionKey, StatefulProfileWalletProps } from '@dao-dao/types' import { getActionBuilderPrefillPath } from '@dao-dao/utils' -import { useActionForKey } from '../../actions' import { useProfile } from '../../hooks' import { ButtonLink } from '../ButtonLink' import { IconButtonLink } from '../IconButtonLink' @@ -74,9 +74,9 @@ export const ProfileWallet = ({ address }: StatefulProfileWalletProps = {}) => { (chainLoadables) => chainLoadables.flatMap((l) => l.valueMaybe() || []) ) - const configureRebalancerActionDefaults = useActionForKey( + const configureRebalancerAction = useInitializedActionForKey( ActionKey.ConfigureRebalancer - )?.useDefaults() + ) return ( { TreasuryHistoryGraph={TreasuryHistoryGraph} accounts={accounts} configureRebalancerHref={ - !readOnly && configureRebalancerActionDefaults + !readOnly && + !configureRebalancerAction.loading && + !configureRebalancerAction.errored ? getActionBuilderPrefillPath([ { - actionKey: ActionKey.ConfigureRebalancer, - data: configureRebalancerActionDefaults, + actionKey: configureRebalancerAction.data.key, + data: configureRebalancerAction.data.defaults, }, ]) : undefined diff --git a/packages/stateful/components/wallet/WalletLazyNftCard.tsx b/packages/stateful/components/wallet/WalletLazyNftCard.tsx index ddb2de111..28fb1edd9 100644 --- a/packages/stateful/components/wallet/WalletLazyNftCard.tsx +++ b/packages/stateful/components/wallet/WalletLazyNftCard.tsx @@ -9,11 +9,10 @@ import { ComponentProps } from 'react' import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' +import { useInitializedActionForKey } from '@dao-dao/stateless' import { ActionKey, ButtonPopupSection } from '@dao-dao/types' import { getActionBuilderPrefillPath, processError } from '@dao-dao/utils' -import { useActionForKey } from '../../actions' -import { TransferNftData } from '../../actions/core/nfts/TransferNft/Component' import { useManageProfile } from '../../hooks' import { ButtonLink } from '../ButtonLink' import { LazyNftCard } from '../nft' @@ -64,9 +63,7 @@ export const WalletLazyNftCard = ( profile.data.nft?.collectionAddress === props.collectionAddress && profile.data.nft?.tokenId === props.tokenId - const transferActionDefaults = useActionForKey( - ActionKey.TransferNft - )?.useDefaults() as TransferNftData | undefined + const transferAction = useInitializedActionForKey(ActionKey.TransferNft) // Setup actions for popup. Prefill with cw20 related actions. const buttonPopupSections: ButtonPopupSection[] = [ @@ -95,7 +92,8 @@ export const WalletLazyNftCard = ( }, ] : []), - ...(transferActionDefaults && + ...(!transferAction.loading && + !transferAction.errored && // If the NFT is staked, don't show the transfer/burn buttons, since the // wallet does not have control. !props.staked @@ -109,9 +107,9 @@ export const WalletLazyNftCard = ( closeOnClick: true, href: getActionBuilderPrefillPath([ { - actionKey: ActionKey.TransferNft, + actionKey: transferAction.data.key, data: { - ...transferActionDefaults, + ...transferAction.data.defaults, collection: props.collectionAddress, tokenId: props.tokenId, recipient: '', diff --git a/packages/stateful/components/wallet/Web3AuthPromptModal.tsx b/packages/stateful/components/wallet/Web3AuthPromptModal.tsx index 151f0c743..622ccb101 100644 --- a/packages/stateful/components/wallet/Web3AuthPromptModal.tsx +++ b/packages/stateful/components/wallet/Web3AuthPromptModal.tsx @@ -4,21 +4,20 @@ import { useRecoilValue } from 'recoil' import { web3AuthPromptAtom } from '@dao-dao/state/recoil' import { - ActionsRenderer, + ActionsMatchAndRender, Button, ChainProvider, CosmosMessageDisplay, Modal, } from '@dao-dao/stateless' -import { ActionAndData, protobufToCwMsg } from '@dao-dao/types' +import { UnifiedCosmosMsg, protobufToCwMsg } from '@dao-dao/types' import { SignDoc, TxBody, } from '@dao-dao/types/protobuf/codegen/cosmos/tx/v1beta1/tx' -import { decodeMessages, getChainForChainId } from '@dao-dao/utils' +import { getChainForChainId } from '@dao-dao/utils' -import { useActionsForMatching } from '../../actions' -import { WalletActionsProvider } from '../../actions/react/provider' +import { WalletActionsProvider } from '../../actions/providers/wallet' import { useWallet } from '../../hooks/useWallet' import { EntityDisplay } from '../EntityDisplay' import { SuspenseLoader } from '../SuspenseLoader' @@ -33,14 +32,14 @@ export const Web3AuthPromptModal = () => { } if (prompt.signData.type === 'direct') { - const messages = decodeMessages( - TxBody.decode(prompt.signData.value.bodyBytes).messages.map( - (msg) => - protobufToCwMsg( - getChainForChainId((prompt.signData.value as SignDoc).chainId), - msg - ).msg - ) + const messages = TxBody.decode( + prompt.signData.value.bodyBytes + ).messages.map( + (msg) => + protobufToCwMsg( + getChainForChainId((prompt.signData.value as SignDoc).chainId), + msg + ).msg ) return { @@ -55,13 +54,6 @@ export const Web3AuthPromptModal = () => { } }, [prompt]) - // Re-create when messages change so that hooks are called in the same order. - const WalletActionsRenderer = useMemo( - () => - makeWalletActionsRenderer(decoded?.type === 'cw' ? decoded.messages : []), - [decoded] - ) - const chainId = prompt && (prompt.signData.type === 'direct' @@ -118,7 +110,7 @@ export const Web3AuthPromptModal = () => { {decoded && (decoded.type === 'cw' ? ( - + ) : ( { ) } -const makeWalletActionsRenderer = (messages: Record[]) => - function WalletActionsRenderer() { - const { t } = useTranslation() - const actionsForMatching = useActionsForMatching() - - // Call relevant action hooks in the same order every time. - const actionData: ActionAndData[] = messages.map((message) => { - const actionMatch = actionsForMatching - .map((action) => ({ - action, - ...action.useDecodedCosmosMsg(message), - })) - .find(({ match }) => match) - - // There should always be a match since custom matches all. This should - // never happen as long as the Custom action exists. - if (!actionMatch?.match) { - throw new Error(t('error.loadingData')) - } - - return { - action: actionMatch.action, - data: actionMatch.data, - } - }) - - return ( - - ) - } +const WalletActionsRenderer = ({ + messages, +}: { + messages: UnifiedCosmosMsg[] +}) => ( + +) diff --git a/packages/stateful/feed/sources/OpenProposals/index.ts b/packages/stateful/feed/sources/OpenProposals/index.ts index 50ade7030..d035f01c4 100644 --- a/packages/stateful/feed/sources/OpenProposals/index.ts +++ b/packages/stateful/feed/sources/OpenProposals/index.ts @@ -12,7 +12,11 @@ import { import { FeedSource } from '@dao-dao/types' import { webSocketChannelNameForDao } from '@dao-dao/utils' -import { useOnWebSocketMessage, useProfile } from '../../../hooks' +import { + useOnWebSocketMessage, + useProfile, + useRefreshGovProposals, +} from '../../../hooks' import { OpenProposalsProposalLine } from './OpenProposalsProposalLineProps' import { feedOpenProposalsSelector } from './state' import { OpenProposalsProposalLineProps } from './types' @@ -21,8 +25,12 @@ export const OpenProposals: FeedSource = { id: 'open_proposals', Renderer: OpenProposalsProposalLine, useData: () => { - const setRefresh = useSetRecoilState(refreshOpenProposalsAtom) - const refresh = useCallback(() => setRefresh((id) => id + 1), [setRefresh]) + const refreshGovProposals = useRefreshGovProposals() + const setRefreshOpenProposals = useSetRecoilState(refreshOpenProposalsAtom) + const refresh = useCallback(() => { + refreshGovProposals() + setRefreshOpenProposals((id) => id + 1) + }, [refreshGovProposals, setRefreshOpenProposals]) const { chains, uniquePublicKeys } = useProfile() diff --git a/packages/stateful/feed/sources/VetoableProposals/index.ts b/packages/stateful/feed/sources/VetoableProposals/index.ts index 5635c1bcd..4ba6e0804 100644 --- a/packages/stateful/feed/sources/VetoableProposals/index.ts +++ b/packages/stateful/feed/sources/VetoableProposals/index.ts @@ -3,7 +3,7 @@ import { constSelector, useSetRecoilState, waitForAll } from 'recoil' import { followingDaosSelector, - refreshOpenProposalsAtom, + refreshProposalsIdAtom, } from '@dao-dao/state/recoil' import { VetoableProposals as Renderer, @@ -22,7 +22,7 @@ export const VetoableProposals: FeedSource< id: 'vetoable_proposals', Renderer, useData: () => { - const setRefresh = useSetRecoilState(refreshOpenProposalsAtom) + const setRefresh = useSetRecoilState(refreshProposalsIdAtom) const refresh = useCallback(() => setRefresh((id) => id + 1), [setRefresh]) const { uniquePublicKeys } = useProfile() diff --git a/packages/stateful/hooks/index.ts b/packages/stateful/hooks/index.ts index 16a56fae9..a89f2ccb7 100644 --- a/packages/stateful/hooks/index.ts +++ b/packages/stateful/hooks/index.ts @@ -6,6 +6,7 @@ export * from './useAwaitNextBlock' export * from './useCfWorkerAuthPostRequest' export * from './useCreateCw1Whitelist' export * from './useDaoClient' +export * from './useDaoGovernanceToken' export * from './useDaoProposalSinglePublishProposal' export * from './useDaoTabs' export * from './useDaoWithWalletSecretNetworkPermit' @@ -29,6 +30,7 @@ export * from './useProposalActionState' export * from './useProposalRelayState' export * from './useProposalVetoState' export * from './useQuerySyncedRecoilState' +export * from './useRefreshGovProposals' export * from './useRefreshProfile' export * from './useSimulateCosmosMsgs' export * from './useSyncWalletSigner' diff --git a/packages/stateful/hooks/useDaoGovernanceToken.ts b/packages/stateful/hooks/useDaoGovernanceToken.ts new file mode 100644 index 000000000..d4b7f07ac --- /dev/null +++ b/packages/stateful/hooks/useDaoGovernanceToken.ts @@ -0,0 +1,19 @@ +import { useSuspenseQuery } from '@tanstack/react-query' + +import { useDaoContextIfAvailable } from '@dao-dao/stateless' +import { GenericToken } from '@dao-dao/types' + +/** + * Get the DAO governance token from the voting module. Returns null if the + * current voting module does not have a governance token or if not in a DAO + * context. Should never error. + */ +export const useDaoGovernanceToken = () => { + const dao = useDaoContextIfAvailable()?.dao + return useSuspenseQuery( + dao?.votingModule.getGovernanceTokenQuery?.() || { + queryKey: ['null'], + queryFn: () => null, + } + ).data +} diff --git a/packages/stateful/hooks/useLoadingGovProposal.tsx b/packages/stateful/hooks/useLoadingGovProposal.tsx index 2487df380..f87e9e1ff 100644 --- a/packages/stateful/hooks/useLoadingGovProposal.tsx +++ b/packages/stateful/hooks/useLoadingGovProposal.tsx @@ -1,13 +1,8 @@ +import { useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' +import { chainQueries } from '@dao-dao/state' import { - chainStakingPoolSelector, - govParamsSelector, - govProposalSelector, - govProposalTallySelector, -} from '@dao-dao/state' -import { - useCachedLoading, useConfiguredChainContext, useLoadingGovProposalTimestampInfo, } from '@dao-dao/stateless' @@ -19,6 +14,7 @@ import { } from '@dao-dao/types' import { formatPercentOf100 } from '@dao-dao/utils' +import { useQueryLoadingDataWithError } from './query' import { useLoadingGovProposalWalletVoteInfo } from './useLoadingGovProposalWalletVoteInfo' // Returns a proposal wrapped in a LoadingData object to allow the UI to respond @@ -28,53 +24,44 @@ export const useLoadingGovProposal = ( ): LoadingData => { const { t } = useTranslation() const { chain } = useConfiguredChainContext() + const queryClient = useQueryClient() - const loadingProposal = useCachedLoading( - govProposalSelector({ + const loadingProposal = useQueryLoadingDataWithError( + chainQueries.govProposal(queryClient, { chainId: chain.chain_id, proposalId: Number(proposalId), - }), - undefined, - // If proposal undefined (due to a selector error), an error will be thrown. - () => { - throw new Error(t('error.loadingData')) - } + }) ) - const loadingProposalTally = useCachedLoading( - govProposalTallySelector({ + const loadingProposalTally = useQueryLoadingDataWithError( + chainQueries.govProposalTally(queryClient, { chainId: chain.chain_id, proposalId: Number(proposalId), - }), - undefined, - // If proposal undefined (due to a selector error), an error will be thrown. - () => { - throw new Error(t('error.loadingData')) - } + }) ) - const loadingGovParams = useCachedLoading( - govParamsSelector({ + const loadingGovParams = useQueryLoadingDataWithError( + chainQueries.govParams(queryClient, { chainId: chain.chain_id, - }), - undefined, - // If undefined (due to a selector error), an error will be thrown. - () => { - throw new Error(t('error.loadingData')) - } + }) ) - const loadingChainStakingPool = useCachedLoading( - chainStakingPoolSelector({ + const loadingTotalNativeStakedBalance = useQueryLoadingDataWithError( + chainQueries.totalNativeStakedBalance({ chainId: chain.chain_id, - }), - undefined, - // If undefined (due to a selector error), an error will be thrown. - () => { - throw new Error(t('error.loadingData')) - } + }) ) + // Throw error if data fails to load. + if ( + loadingProposal.errored || + loadingProposalTally.errored || + loadingGovParams.errored || + loadingTotalNativeStakedBalance.errored + ) { + throw new Error(t('error.loadingData')) + } + const loadingTimestampInfo = useLoadingGovProposalTimestampInfo( loadingProposal.loading ? undefined : loadingProposal.data?.proposal ) @@ -90,8 +77,8 @@ export const useLoadingGovProposal = ( !loadingProposalTally.data || loadingGovParams.loading || !loadingGovParams.data || - loadingChainStakingPool.loading || - !loadingChainStakingPool.data || + loadingTotalNativeStakedBalance.loading || + !loadingTotalNativeStakedBalance.data || loadingTimestampInfo.loading ) { return { loading: true } @@ -101,7 +88,7 @@ export const useLoadingGovProposal = ( const noVotes = Number(loadingProposalTally.data.no) const abstainVotes = Number(loadingProposalTally.data.abstain) const noWithVetoVotes = Number(loadingProposalTally.data.noWithVeto) - const totalVotingPower = Number(loadingChainStakingPool.data.bondedTokens) + const totalVotingPower = Number(loadingTotalNativeStakedBalance.data) const turnoutTotal = yesVotes + noVotes + abstainVotes + noWithVetoVotes const turnoutPercent = turnoutTotal ? (turnoutTotal / totalVotingPower) * 100 diff --git a/packages/stateful/hooks/useLoadingGovProposalWalletVoteInfo.ts b/packages/stateful/hooks/useLoadingGovProposalWalletVoteInfo.ts index e2aea57f2..707c08c20 100644 --- a/packages/stateful/hooks/useLoadingGovProposalWalletVoteInfo.ts +++ b/packages/stateful/hooks/useLoadingGovProposalWalletVoteInfo.ts @@ -1,48 +1,35 @@ -import { constSelector } from 'recoil' +import { useQueryClient } from '@tanstack/react-query' -import { govProposalVoteSelector } from '@dao-dao/state' -import { useCachedLoading, useConfiguredChainContext } from '@dao-dao/stateless' -import { GovProposalWalletVoteInfo, LoadingData } from '@dao-dao/types' +import { chainQueries } from '@dao-dao/state' +import { useConfiguredChainContext } from '@dao-dao/stateless' +import { GovProposalWalletVoteInfo, LoadingDataWithError } from '@dao-dao/types' +import { useQueryLoadingDataWithError } from './query' import { useWallet } from './useWallet' export const useLoadingGovProposalWalletVoteInfo = ( proposalId: number | string -): LoadingData => { +): LoadingDataWithError => { const { chain: { chain_id: chainId }, } = useConfiguredChainContext() const { address: voter } = useWallet() + const queryClient = useQueryClient() - const loadingWalletVote = useCachedLoading( + return useQueryLoadingDataWithError( voter - ? govProposalVoteSelector({ + ? chainQueries.govProposalVote(queryClient, { chainId, proposalId: Number(proposalId), voter, }) - : constSelector(undefined), - undefined + : undefined, + (data): GovProposalWalletVoteInfo => ({ + // If no votes, return null to indicate no vote. + vote: + data.length === 0 + ? null + : data.sort((a, b) => Number(b.weight) - Number(a.weight)), + }) ) - - const walletVoteInfo: LoadingData = - loadingWalletVote.loading - ? { - loading: true, - } - : { - loading: false, - updating: loadingWalletVote.updating, - data: { - vote: - // If no votes, return undefined to indicate has not voted. - !loadingWalletVote.data || loadingWalletVote.data.length === 0 - ? undefined - : loadingWalletVote.data.sort( - (a, b) => Number(b.weight) - Number(a.weight) - ), - }, - } - - return walletVoteInfo } diff --git a/packages/stateful/hooks/useProposalVetoState.tsx b/packages/stateful/hooks/useProposalVetoState.tsx index e29838ff5..d81e40155 100644 --- a/packages/stateful/hooks/useProposalVetoState.tsx +++ b/packages/stateful/hooks/useProposalVetoState.tsx @@ -216,13 +216,12 @@ export const useProposalVetoState = ({ prefill: getDaoProposalSinglePrefill({ actions: [ { - actionKey: ActionKey.VetoOrEarlyExecuteDaoProposal, + actionKey: ActionKey.VetoProposal, data: { chainId, coreAddress, proposalModuleAddress: proposalModule.address, proposalId: proposalNumber, - action: 'veto', }, }, ], @@ -304,13 +303,12 @@ export const useProposalVetoState = ({ prefill: getDaoProposalSinglePrefill({ actions: [ { - actionKey: ActionKey.VetoOrEarlyExecuteDaoProposal, + actionKey: ActionKey.ExecuteProposal, data: { chainId, coreAddress, proposalModuleAddress: proposalModule.address, proposalId: proposalNumber, - action: 'earlyExecute', }, }, ], diff --git a/packages/stateful/hooks/useRefreshGovProposals.ts b/packages/stateful/hooks/useRefreshGovProposals.ts new file mode 100644 index 000000000..8e02cd20a --- /dev/null +++ b/packages/stateful/hooks/useRefreshGovProposals.ts @@ -0,0 +1,58 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useCallback } from 'react' +import { useSetRecoilState } from 'recoil' + +import { chainQueries, indexerQueries } from '@dao-dao/state/query' +import { refreshGovProposalsAtom } from '@dao-dao/state/recoil' +import { useChain } from '@dao-dao/stateless' + +export const useRefreshGovProposals = () => { + const { chain_id: chainId } = useChain() + + const queryClient = useQueryClient() + const setRefreshProposal = useSetRecoilState(refreshGovProposalsAtom(chainId)) + + const refreshProposal = useCallback(() => { + // Search/List + queryClient.invalidateQueries({ + queryKey: chainQueries.searchGovProposals({ + chainId, + }).queryKey, + }) + queryClient.invalidateQueries({ + queryKey: chainQueries.searchAndDecodeGovProposals(queryClient, { + chainId, + }).queryKey, + }) + queryClient.invalidateQueries({ + queryKey: chainQueries.govProposals(queryClient, { + chainId, + }).queryKey, + }) + + // Proposal + queryClient.invalidateQueries({ + queryKey: indexerQueries.queryGeneric(queryClient, { + chainId, + formula: 'gov/proposal', + }).queryKey, + }) + queryClient.invalidateQueries({ + queryKey: ['chain', 'govProposal', { chainId }], + }) + queryClient.invalidateQueries({ + queryKey: ['chain', 'govProposalTally', { chainId }], + }) + queryClient.invalidateQueries({ + queryKey: ['chain', 'govProposalVote', { chainId }], + }) + queryClient.invalidateQueries({ + queryKey: ['chain', 'govProposalVotes', { chainId }], + }) + + // Recoil + setRefreshProposal((id) => id + 1) + }, [setRefreshProposal, queryClient, chainId]) + + return refreshProposal +} diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/actions/UpdatePreProposeConfig/UpdatePreProposeConfigComponent.tsx b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/actions/UpdatePreProposeConfig/UpdatePreProposeConfigComponent.tsx index 41c109339..88927bb6e 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/actions/UpdatePreProposeConfig/UpdatePreProposeConfigComponent.tsx +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/actions/UpdatePreProposeConfig/UpdatePreProposeConfigComponent.tsx @@ -11,6 +11,7 @@ import { MoneyEmoji, SegmentedControls, TokenInput, + useActionOptions, } from '@dao-dao/stateless' import { ActionComponent, @@ -28,8 +29,6 @@ import { validateRequired, } from '@dao-dao/utils' -import { useActionOptions } from '../../../../../../actions' - const DepositRefundPolicyValues = Object.values(DepositRefundPolicy) export interface UpdatePreProposeConfigData { @@ -132,12 +131,13 @@ export const UpdatePreProposeConfigComponent: ActionComponent< return (
-
+
-
-

- {t('form.proposalDepositTitle')} -

+
+
+ +

{t('form.proposalDepositTitle')}

+
+

{t('form.proposalDepositDescription')}

@@ -272,11 +273,14 @@ export const UpdatePreProposeConfigComponent: ActionComponent< )}
-
+
-

- {t('form.proposalSubmissionPolicyTitle')} -

+
+ +

+ {t('form.proposalSubmissionPolicyTitle')} +

+

{t('form.proposalSubmissionPolicyDescription')}

diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/actions/UpdatePreProposeConfig/index.tsx b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/actions/UpdatePreProposeConfig/index.tsx index 5bb9dbd04..e46b604d2 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/actions/UpdatePreProposeConfig/index.tsx +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/actions/UpdatePreProposeConfig/index.tsx @@ -1,24 +1,27 @@ -import { useCallback, useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { constSelector, useRecoilValueLoadable } from 'recoil' import { - DaoPreProposeMultipleSelectors, + contractQueries, + daoPreProposeMultipleQueries, genericTokenSelector, + tokenQueries, } from '@dao-dao/state' -import { GearEmoji, useCachedLoadingWithError } from '@dao-dao/stateless' +import { ActionBase, GearEmoji, useActionOptions } from '@dao-dao/stateless' import { ActionComponent, ActionKey, - ActionMaker, + ActionMatch, + ActionOptions, DepositRefundPolicy, Feature, IProposalModuleBase, + PreProposeModule, + ProcessedMessage, TokenType, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, + UnifiedCosmosMsg, } from '@dao-dao/types' import { ExecuteMsg, @@ -31,29 +34,24 @@ import { getNativeTokenForChainId, isFeatureSupportedByVersion, isValidBech32Address, - makeWasmMessage, + makeExecuteSmartContractMessage, + objectMatchesStructure, + tokensEqual, } from '@dao-dao/utils' -import { - useActionOptions, - useMsgExecutesContract, -} from '../../../../../../actions' -import { useVotingModuleAdapter } from '../../../../../../voting-module-adapter' +import { useDaoGovernanceToken } from '../../../../../../hooks' import { UpdatePreProposeConfigComponent, UpdatePreProposeConfigData, } from './UpdatePreProposeConfigComponent' -export const Component: ActionComponent = (props) => { +const Component: ActionComponent = (props) => { const { t } = useTranslation() const { chain: { chain_id: chainId, bech32_prefix: bech32Prefix }, } = useActionOptions() - const { - hooks: { useCommonGovernanceTokenInfo }, - } = useVotingModuleAdapter() - const governanceToken = useCommonGovernanceTokenInfo?.() + const governanceToken = useDaoGovernanceToken() ?? undefined const { fieldNamePrefix } = props @@ -121,316 +119,307 @@ export const Component: ActionComponent = (props) => { ) } -export const makeUpdatePreProposeConfigActionMaker = - ({ - prePropose, - }: IProposalModuleBase): ActionMaker => - ({ t, chain: { chain_id: chainId } }) => { +export class DaoProposalMultipleUpdatePreProposeConfigAction extends ActionBase { + public readonly key = ActionKey.UpdatePreProposeConfig + public readonly Component = Component + + private prePropose: PreProposeModule + + constructor( + options: ActionOptions, + private proposalModule: IProposalModuleBase + ) { // Only when pre propose address present. - if (!prePropose) { - return null + if (!proposalModule.prePropose) { + throw new Error( + 'Pre-propose config can only be updated when a pre-propose module is being used.' + ) } - const preProposeAddress = prePropose.address + super(options, { + Icon: GearEmoji, + label: options.t('proposalModuleLabel.DaoProposalMultiple'), + // Unused. + description: '', + }) - const useDefaults: UseDefaults = () => { - const { - hooks: { useCommonGovernanceTokenInfo }, - } = useVotingModuleAdapter() - const { denomOrAddress: governanceTokenDenomOrAddress } = - useCommonGovernanceTokenInfo?.() ?? {} + this.prePropose = proposalModule.prePropose + } - const configLoading = useCachedLoadingWithError( - DaoPreProposeMultipleSelectors.configSelector({ - chainId, - contractAddress: preProposeAddress, - params: [], - }) - ) + async setup() { + const governanceToken = this.proposalModule.dao.votingModule + .getGovernanceTokenQuery + ? await this.options.queryClient.fetchQuery( + this.proposalModule.dao.votingModule.getGovernanceTokenQuery() + ) + : undefined + + const config = await this.options.queryClient.fetchQuery( + daoPreProposeMultipleQueries.config(this.options.queryClient, { + chainId: this.proposalModule.dao.chainId, + contractAddress: this.prePropose.address, + }) + ) + + // The config response only contains `native` or `cw20`, as + // `voting_module_token` is only passed in on execution. + const token = config.deposit_info + ? await this.options.queryClient.fetchQuery( + tokenQueries.info(this.options.queryClient, { + chainId: this.proposalModule.dao.chainId, + type: + 'native' in config.deposit_info.denom + ? TokenType.Native + : TokenType.Cw20, + denomOrAddress: + 'native' in config.deposit_info.denom + ? config.deposit_info.denom.native + : config.deposit_info.denom.cw20, + }) + ) + : undefined + + const isVotingModuleToken = + governanceToken && token && tokensEqual(governanceToken, token) + + const depositRequired = !!config.deposit_info + const depositInfo: UpdatePreProposeConfigData['depositInfo'] = + config.deposit_info + ? { + amount: convertMicroDenomToDenomWithDecimals( + config.deposit_info.amount, + token?.decimals ?? 0 + ), + type: isVotingModuleToken + ? 'voting_module_token' + : 'native' in config.deposit_info.denom + ? 'native' + : 'cw20', + denomOrAddress: isVotingModuleToken + ? governanceToken.denomOrAddress + : 'native' in config.deposit_info.denom + ? config.deposit_info.denom.native + : config.deposit_info.denom.cw20, + token, + refundPolicy: config.deposit_info.refund_policy, + } + : { + amount: 1, + type: 'native', + denomOrAddress: getNativeTokenForChainId( + this.proposalModule.dao.chainId + ).denomOrAddress, + token: undefined, + refundPolicy: DepositRefundPolicy.OnlyPassed, + } - // The config response only contains `native` or `cw20`, as - // `voting_module_token` is only passed in an execution. The contract - // converts it to `cw20`. - const tokenLoading = useCachedLoadingWithError( - configLoading.loading || configLoading.errored - ? undefined - : configLoading.data.deposit_info - ? genericTokenSelector({ - chainId, - type: - 'native' in configLoading.data.deposit_info.denom - ? TokenType.Native - : TokenType.Cw20, - denomOrAddress: - 'native' in configLoading.data.deposit_info.denom - ? configLoading.data.deposit_info.denom.native - : configLoading.data.deposit_info.denom.cw20, - }) - : constSelector(undefined) + this.defaults = { + depositRequired, + depositInfo, + anyoneCanPropose: isFeatureSupportedByVersion( + Feature.GranularSubmissionPolicy, + this.prePropose.version ) + ? !!config.submission_policy && 'anyone' in config.submission_policy + : !!config.open_proposal_submission, + } + } - if (configLoading.loading || tokenLoading.loading) { - return - } - if (configLoading.errored) { - return configLoading.error - } - if (tokenLoading.errored) { - return tokenLoading.error - } - - const token = tokenLoading.data - const config = configLoading.data - - const isVotingModuleToken = - governanceTokenDenomOrAddress && - token && - token.denomOrAddress === governanceTokenDenomOrAddress + async encode({ + depositRequired, + depositInfo, + anyoneCanPropose, + }: UpdatePreProposeConfigData): Promise { + const votingModuleTokenType = + depositRequired && depositInfo.type === 'voting_module_token' + ? depositInfo.token?.type + : false + if (votingModuleTokenType === undefined) { + throw new Error('Voting module token not loaded.') + } - const depositRequired = !!config.deposit_info - const depositInfo: UpdatePreProposeConfigData['depositInfo'] = - config.deposit_info + const updateConfigMessage: ExecuteMsg = { + update_config: { + deposit_info: depositRequired ? { - amount: convertMicroDenomToDenomWithDecimals( - config.deposit_info.amount, - token?.decimals ?? 0 + amount: convertDenomToMicroDenomStringWithDecimals( + depositInfo.amount, + depositInfo.token?.decimals ?? 0 ), - type: isVotingModuleToken - ? 'voting_module_token' - : 'native' in config.deposit_info.denom - ? 'native' - : 'cw20', - denomOrAddress: isVotingModuleToken - ? governanceTokenDenomOrAddress - : 'native' in config.deposit_info.denom - ? config.deposit_info.denom.native - : config.deposit_info.denom.cw20, - token, - refundPolicy: config.deposit_info.refund_policy, - } - : { - amount: 1, - type: 'native', - denomOrAddress: getNativeTokenForChainId(chainId).denomOrAddress, - token: undefined, - refundPolicy: DepositRefundPolicy.OnlyPassed, + denom: + depositInfo.type === 'voting_module_token' + ? { + voting_module_token: { + token_type: + votingModuleTokenType === TokenType.Native + ? 'native' + : votingModuleTokenType === TokenType.Cw20 + ? 'cw20' + : // Cause a chain error. Should never happen. + ('invalid' as never), + }, + } + : { + token: { + denom: + depositInfo.type === 'native' + ? { + native: depositInfo.denomOrAddress, + } + : // depositInfo.type === 'cw20' + { + cw20: depositInfo.denomOrAddress, + }, + }, + }, + refund_policy: depositInfo.refundPolicy, } - - return { - depositRequired, - depositInfo, - anyoneCanPropose: isFeatureSupportedByVersion( + : null, + ...(isFeatureSupportedByVersion( Feature.GranularSubmissionPolicy, - prePropose.version + this.prePropose.version ) - ? !!config.submission_policy && 'anyone' in config.submission_policy - : !!config.open_proposal_submission, - } - } - - const useTransformToCosmos: UseTransformToCosmos< - UpdatePreProposeConfigData - > = () => - useCallback( - ({ - depositRequired, - depositInfo, - anyoneCanPropose, - }: UpdatePreProposeConfigData) => { - const votingModuleTokenType = - depositRequired && depositInfo.type === 'voting_module_token' - ? depositInfo.token?.type - : false - if (votingModuleTokenType === undefined) { - throw new Error(t('error.loadingData')) - } - - const updateConfigMessage: ExecuteMsg = { - update_config: { - deposit_info: depositRequired - ? { - amount: convertDenomToMicroDenomStringWithDecimals( - depositInfo.amount, - depositInfo.token?.decimals ?? 0 - ), - denom: - depositInfo.type === 'voting_module_token' - ? { - voting_module_token: { - token_type: - votingModuleTokenType === TokenType.Native - ? 'native' - : votingModuleTokenType === TokenType.Cw20 - ? 'cw20' - : // Cause a chain error. Should never happen. - ('invalid' as never), - }, - } - : { - token: { - denom: - depositInfo.type === 'native' - ? { - native: depositInfo.denomOrAddress, - } - : // depositInfo.type === 'cw20' - { - cw20: depositInfo.denomOrAddress, - }, - }, - }, - refund_policy: depositInfo.refundPolicy, - } - : null, - ...(isFeatureSupportedByVersion( - Feature.GranularSubmissionPolicy, - prePropose.version - ) + ? { + submission_policy: anyoneCanPropose ? { - submission_policy: anyoneCanPropose - ? { - anyone: { - denylist: [], - }, - } - : { - specific: { - dao_members: true, - allowlist: [], - denylist: [], - }, - }, + anyone: { + denylist: [], + }, } : { - open_proposal_submission: anyoneCanPropose, - }), - }, - } - - return makeWasmMessage({ - wasm: { - execute: { - contract_addr: preProposeAddress, - funds: [], - msg: updateConfigMessage, - }, - }, - }) - }, - [] - ) - - const useDecodedCosmosMsg: UseDecodedCosmosMsg< - UpdatePreProposeConfigData - > = (msg: Record) => { - const isUpdatePreProposeConfig = useMsgExecutesContract( - msg, - DAO_PRE_PROPOSE_MULTIPLE_CONTRACT_NAMES, - { - update_config: { - deposit_info: {}, - }, - } - ) - - const configDepositInfo = msg.wasm?.execute?.msg?.update_config - ?.deposit_info as UncheckedDepositInfo | null | undefined - - const { - hooks: { useCommonGovernanceTokenInfo }, - } = useVotingModuleAdapter() - const governanceToken = useCommonGovernanceTokenInfo?.() - - const token = useCachedLoadingWithError( - isUpdatePreProposeConfig && - configDepositInfo && - isUpdatePreProposeConfig - ? 'voting_module_token' in configDepositInfo.denom - ? constSelector(governanceToken) - : genericTokenSelector({ - chainId, - type: - 'native' in configDepositInfo.denom.token.denom - ? TokenType.Native - : TokenType.Cw20, - denomOrAddress: - 'native' in configDepositInfo.denom.token.denom - ? configDepositInfo.denom.token.denom.native - : configDepositInfo.denom.token.denom.cw20, - }) - : constSelector(undefined) - ) - - if (!isUpdatePreProposeConfig || token.loading || token.errored) { - return { match: false } - } - - const anyoneCanPropose = - // < v2.5.0 - 'open_proposal_submission' in msg.wasm.execute.msg.update_config - ? !!msg.wasm.execute.msg.update_config.open_proposal_submission - : // >= v2.5.0 - 'submission_policy' in msg.wasm.execute.msg.update_config - ? 'anyone' in msg.wasm.execute.msg.update_config.submission_policy - : undefined + specific: { + dao_members: true, + allowlist: [], + denylist: [], + }, + }, + } + : { + open_proposal_submission: anyoneCanPropose, + }), + }, + } - if (anyoneCanPropose === undefined) { - return { match: false } - } + return makeExecuteSmartContractMessage({ + chainId: this.proposalModule.dao.chainId, + contractAddress: this.prePropose.address, + sender: this.options.address, + msg: updateConfigMessage, + }) + } - if (!configDepositInfo || !token.data) { - return { - data: { - depositRequired: false, - depositInfo: { - amount: 1, - type: 'native', - denomOrAddress: getNativeTokenForChainId(chainId).denomOrAddress, - refundPolicy: DepositRefundPolicy.OnlyPassed, + async match([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): Promise { + return ( + objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + update_config: {}, }, - anyoneCanPropose, }, - match: true, - } - } + }, + }) && + chainId === this.proposalModule.dao.chainId && + (await this.options.queryClient.fetchQuery( + contractQueries.isContract(this.options.queryClient, { + chainId, + address: decodedMessage.wasm.execute.contract_addr, + nameOrNames: DAO_PRE_PROPOSE_MULTIPLE_CONTRACT_NAMES, + }) + )) + ) + } - const type: UpdatePreProposeConfigData['depositInfo']['type'] = - 'voting_module_token' in configDepositInfo.denom - ? 'voting_module_token' - : 'native' in configDepositInfo.denom.token.denom - ? 'native' - : 'cw20' - - const depositInfo: UpdatePreProposeConfigData['depositInfo'] = { - amount: convertMicroDenomToDenomWithDecimals( - configDepositInfo.amount, - token.data.decimals - ), - type, - denomOrAddress: token.data.denomOrAddress, - token: token.data, - refundPolicy: configDepositInfo.refund_policy, - } + async decode([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): Promise { + const config = decodedMessage.wasm.execute.msg.update_config + const configDepositInfo = config.deposit_info as + | UncheckedDepositInfo + | null + | undefined + + const token = configDepositInfo + ? 'voting_module_token' in configDepositInfo.denom + ? this.proposalModule.dao.votingModule.getGovernanceTokenQuery + ? await this.options.queryClient.fetchQuery( + this.proposalModule.dao.votingModule.getGovernanceTokenQuery() + ) + : undefined + : await this.options.queryClient.fetchQuery( + tokenQueries.info(this.options.queryClient, { + chainId, + type: + 'native' in configDepositInfo.denom.token.denom + ? TokenType.Native + : TokenType.Cw20, + denomOrAddress: + 'native' in configDepositInfo.denom.token.denom + ? configDepositInfo.denom.token.denom.native + : configDepositInfo.denom.token.denom.cw20, + }) + ) + : undefined + + const anyoneCanPropose = + // < v2.5.0 + 'open_proposal_submission' in config + ? !!config.open_proposal_submission + : // >= v2.5.0 + 'submission_policy' in config + ? 'anyone' in config.submission_policy + : undefined + + // anyoneCanPropose should be defined + if (anyoneCanPropose === undefined) { + throw new Error('Invalid config update message.') + } + if (!configDepositInfo || !token) { return { - data: { - depositRequired: true, - depositInfo, - anyoneCanPropose, + depositRequired: false, + depositInfo: { + amount: 1, + type: 'native', + denomOrAddress: getNativeTokenForChainId(chainId).denomOrAddress, + refundPolicy: DepositRefundPolicy.OnlyPassed, }, - match: true, + anyoneCanPropose, } } + const type: UpdatePreProposeConfigData['depositInfo']['type'] = + 'voting_module_token' in configDepositInfo.denom + ? 'voting_module_token' + : 'native' in configDepositInfo.denom.token.denom + ? 'native' + : 'cw20' + + const depositInfo: UpdatePreProposeConfigData['depositInfo'] = { + amount: convertMicroDenomToDenomWithDecimals( + configDepositInfo.amount, + token.decimals + ), + type, + denomOrAddress: token.denomOrAddress, + token, + refundPolicy: configDepositInfo.refund_policy, + } + return { - key: ActionKey.UpdatePreProposeConfig, - Icon: GearEmoji, - label: t('proposalModuleLabel.DaoProposalMultiple'), - // Not used. - description: '', - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, + depositRequired: true, + depositInfo, + anyoneCanPropose, } } +} diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/actions/UpdateProposalConfig/UpdateProposalConfigComponent.tsx b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/actions/UpdateProposalConfig/UpdateProposalConfigComponent.tsx index a4727c269..617a5615b 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/actions/UpdateProposalConfig/UpdateProposalConfigComponent.tsx +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/actions/UpdateProposalConfig/UpdateProposalConfigComponent.tsx @@ -5,9 +5,9 @@ import { useTranslation } from 'react-i18next' import { ClockEmoji, - FormSwitch, FormSwitchCard, InputErrorMessage, + KeyEmoji, NumberInput, PeopleEmoji, ProposalVetoConfigurer, @@ -86,29 +86,17 @@ export const UpdateProposalConfigComponent: ActionComponent< return (
- - -
-
-

- {t('form.quorumTitle')} -

- -

{t('form.quorumDescription')}

+
+
+
+ +

{t('form.quorumTitle')}

+
+

+ {t('form.quorumDescription')} +

-
+
{percentageQuorumSelected && (
-
-
-

- {t('form.votingDurationTitle')} -

-

+

+
+
+ +

{t('form.votingDurationTitle')}

+
+ +

{t('form.votingDurationDescription')}

-
+
-
-
-

- {t('form.allowRevotingTitle')} -

-

{t('form.allowRevotingDescription')}

+
+
+
+ +

{t('form.onlyMembersExecuteTitle')}

+
+ +
-
- + {t('form.onlyMembersExecuteDescription')} +

+
+ +
+
+
+ +

{t('form.allowRevotingTitle')}

+
+ +
+

+ {t('form.allowRevotingDescription')} +

{version && isFeatureSupportedByVersion(Feature.Veto, version) && ( -
-
-
-

- {t('title.veto')} -

+
+
+
+
+ +

{t('title.veto')}

+
-

{t('info.vetoDescription')}

+

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

{veto.enabled && ( diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/actions/UpdateProposalConfig/index.tsx b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/actions/UpdateProposalConfig/index.tsx index db94f2013..26f15a757 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/actions/UpdateProposalConfig/index.tsx +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/actions/UpdateProposalConfig/index.tsx @@ -1,26 +1,26 @@ -import { useCallback, useEffect } from 'react' +import { useEffect } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' -import { constSelector, useRecoilValueLoadable } from 'recoil' import { - Cw1WhitelistSelectors, - DaoProposalMultipleSelectors, -} from '@dao-dao/state/recoil' + cw1WhitelistExtraQueries, + daoProposalMultipleQueries, +} from '@dao-dao/state/query' import { + ActionBase, BallotDepositEmoji, - useCachedLoadingWithError, + useActionOptions, } from '@dao-dao/stateless' import { ActionChainContextType, ActionComponent, ActionKey, - ActionMaker, + ActionMatch, + ActionOptions, Feature, IProposalModuleBase, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, + ProcessedMessage, + UnifiedCosmosMsg, } from '@dao-dao/types' import { ExecuteMsg, @@ -28,19 +28,15 @@ import { VotingStrategy, } from '@dao-dao/types/contracts/DaoProposalMultiple' import { - DAO_PROPOSAL_MULTIPLE_CONTRACT_NAMES, convertCosmosVetoConfigToVeto, convertDurationToDurationWithUnits, convertDurationWithUnitsToDuration, convertVetoConfigToCosmos, isFeatureSupportedByVersion, - makeWasmMessage, + makeExecuteSmartContractMessage, + objectMatchesStructure, } from '@dao-dao/utils' -import { - useActionOptions, - useMsgExecutesContract, -} from '../../../../../../actions' import { AddressInput } from '../../../../../../components' import { useCreateCw1Whitelist } from '../../../../../../hooks' import { @@ -86,270 +82,245 @@ const typePercentageToPercentageThreshold = ( } } -export const makeUpdateProposalConfigActionMaker = ({ - version, - address: proposalModuleAddress, -}: IProposalModuleBase): ActionMaker => { - const Component: ActionComponent = (props) => { - const { t } = useTranslation() - const { setError, clearErrors, watch, trigger } = - useFormContext() - - const vetoAddressesLength = watch( - (props.fieldNamePrefix + 'veto.addresses') as 'veto.addresses' - ).length - const vetoCw1WhitelistAddress = watch( - (props.fieldNamePrefix + - 'veto.cw1WhitelistAddress') as 'veto.cw1WhitelistAddress' - ) +export class DaoProposalMultipleUpdateConfigAction extends ActionBase { + public readonly key = ActionKey.UpdateProposalConfig + public readonly Component: ActionComponent< + undefined, + UpdateProposalConfigData + > - const { chainContext } = useActionOptions() - if (chainContext.type !== ActionChainContextType.Supported) { - throw new Error('Unsupported chain context') - } - - const { - creatingCw1Whitelist: creatingCw1WhitelistVetoers, - createCw1Whitelist: createCw1WhitelistVetoers, - } = useCreateCw1Whitelist({ - // Trigger veto address field validations. - validation: async () => { - await trigger( - (props.fieldNamePrefix + 'veto.addresses') as 'veto.addresses', - { - shouldFocus: true, - } - ) - }, - contractLabel: 'Multi-Vetoer cw1-whitelist', + constructor( + options: ActionOptions, + private proposalModule: IProposalModuleBase + ) { + super(options, { + Icon: BallotDepositEmoji, + label: options.t('proposalModuleLabel.DaoProposalMultiple'), + // Unused. + description: '', }) - // Prevent action from being submitted if the cw1-whitelist contract has not - // yet been created and it needs to be. - useEffect(() => { - if (vetoAddressesLength > 1 && !vetoCw1WhitelistAddress) { - setError( - (props.fieldNamePrefix + - 'veto.cw1WhitelistAddress') as 'veto.cw1WhitelistAddress', - { - type: 'manual', - message: t('error.accountListNeedsSaving'), - } - ) - } else { - clearErrors( - (props.fieldNamePrefix + - 'veto.cw1WhitelistAddress') as 'veto.cw1WhitelistAddress' - ) - } - }, [ - setError, - clearErrors, - t, - props.fieldNamePrefix, - vetoAddressesLength, - vetoCw1WhitelistAddress, - ]) + this.Component = function DaoProposalMultipleUpdateConfigActionComponent( + props + ) { + const { t } = useTranslation() + const { setError, clearErrors, watch, trigger } = + useFormContext() - return ( - - ) - } - - return ({ t, chain: { chain_id: chainId } }) => { - const useDefaults: UseDefaults = () => { - const proposalModuleConfig = useCachedLoadingWithError( - DaoProposalMultipleSelectors.configSelector({ - chainId, - contractAddress: proposalModuleAddress, - }) - ) - - // Attempt to load cw1-whitelist admins if the vetoer is set. Will only - // succeed if the vetoer is a cw1-whitelist contract. Otherwise it returns - // undefined. - const cw1WhitelistAdminsLoadable = useRecoilValueLoadable( - !proposalModuleConfig.loading && - !proposalModuleConfig.errored && - proposalModuleConfig.data.veto - ? Cw1WhitelistSelectors.adminsIfCw1Whitelist({ - chainId, - contractAddress: proposalModuleConfig.data.veto.vetoer, - }) - : constSelector(undefined) + const vetoAddressesLength = watch( + (props.fieldNamePrefix + 'veto.addresses') as 'veto.addresses' + ).length + const vetoCw1WhitelistAddress = watch( + (props.fieldNamePrefix + + 'veto.cw1WhitelistAddress') as 'veto.cw1WhitelistAddress' ) - if ( - proposalModuleConfig.loading || - cw1WhitelistAdminsLoadable.state === 'loading' - ) { - return - } else if (proposalModuleConfig.errored) { - return proposalModuleConfig.error - } else if (cw1WhitelistAdminsLoadable.state === 'hasError') { - return cw1WhitelistAdminsLoadable.contents + const { chainContext } = useActionOptions() + if (chainContext.type !== ActionChainContextType.Supported) { + throw new Error('Unsupported chain context') } - const onlyMembersExecute = proposalModuleConfig.data.only_members_execute + const { + creatingCw1Whitelist: creatingCw1WhitelistVetoers, + createCw1Whitelist: createCw1WhitelistVetoers, + } = useCreateCw1Whitelist({ + // Trigger veto address field validations. + validation: async () => { + await trigger( + (props.fieldNamePrefix + 'veto.addresses') as 'veto.addresses', + { + shouldFocus: true, + } + ) + }, + contractLabel: 'Multi-Vetoer cw1-whitelist', + }) - const allowRevoting = proposalModuleConfig.data.allow_revoting - const votingStrategy = proposalModuleConfig.data.voting_strategy + // Prevent action from being submitted if the cw1-whitelist contract has + // not yet been created and it needs to be. + useEffect(() => { + if (vetoAddressesLength > 1 && !vetoCw1WhitelistAddress) { + setError( + (props.fieldNamePrefix + + 'veto.cw1WhitelistAddress') as 'veto.cw1WhitelistAddress', + { + type: 'manual', + message: t('error.accountListNeedsSaving'), + } + ) + } else { + clearErrors( + (props.fieldNamePrefix + + 'veto.cw1WhitelistAddress') as 'veto.cw1WhitelistAddress' + ) + } + }, [ + setError, + clearErrors, + t, + props.fieldNamePrefix, + vetoAddressesLength, + vetoCw1WhitelistAddress, + ]) - return { - onlyMembersExecute, - votingDuration: convertDurationToDurationWithUnits( - proposalModuleConfig.data.max_voting_period - ), - allowRevoting, - veto: convertCosmosVetoConfigToVeto( - proposalModuleConfig.data.veto, - cw1WhitelistAdminsLoadable.valueMaybe() - ), - ...votingStrategyToProcessedQuorum(votingStrategy), - } + return ( + + ) } + } - const useTransformToCosmos: UseTransformToCosmos< - UpdateProposalConfigData - > = () => { - const proposalModuleConfig = useCachedLoadingWithError( - DaoProposalMultipleSelectors.configSelector({ - chainId, - contractAddress: proposalModuleAddress, - }) - ) + async setup() { + const config = await this.options.queryClient.fetchQuery( + daoProposalMultipleQueries.config(this.options.queryClient, { + chainId: this.proposalModule.dao.chainId, + contractAddress: this.proposalModule.address, + }) + ) - return useCallback( - (data: UpdateProposalConfigData) => { - if (proposalModuleConfig.loading) { - return - } else if (proposalModuleConfig.errored) { - throw proposalModuleConfig.error - } + // Attempt to load cw1-whitelist admins if the vetoer is set. Will only + // succeed if the vetoer is a cw1-whitelist contract. Otherwise it returns + // null. + const cw1WhitlistAdmins = config.veto + ? await this.options.queryClient.fetchQuery( + cw1WhitelistExtraQueries.adminsIfCw1Whitelist( + this.options.queryClient, + { + chainId: this.proposalModule.dao.chainId, + address: config.veto.vetoer, + } + ) + ) + : null - const updateConfigMessage: ExecuteMsg = { - update_config: { - voting_strategy: { - single_choice: { - quorum: typePercentageToPercentageThreshold( - data.quorumType, - data.quorumPercentage - ), - }, - }, - max_voting_period: convertDurationWithUnitsToDuration( - data.votingDuration - ), - only_members_execute: data.onlyMembersExecute, - allow_revoting: data.allowRevoting, - // If veto is supported... - ...(version && - isFeatureSupportedByVersion(Feature.Veto, version) && { - veto: convertVetoConfigToCosmos(data.veto), - }), - // Pass through because we don't support changing them yet. - dao: proposalModuleConfig.data.dao, - close_proposal_on_execution_failure: - proposalModuleConfig.data.close_proposal_on_execution_failure, - min_voting_period: proposalModuleConfig.data.min_voting_period, - }, - } + this.defaults = { + onlyMembersExecute: config.only_members_execute, + votingDuration: convertDurationToDurationWithUnits( + config.max_voting_period + ), + allowRevoting: config.allow_revoting, + veto: convertCosmosVetoConfigToVeto(config.veto, cw1WhitlistAdmins), + ...votingStrategyToProcessedQuorum(config.voting_strategy), + } + } - return makeWasmMessage({ - wasm: { - execute: { - contract_addr: proposalModuleAddress, - funds: [], - msg: updateConfigMessage, - }, - }, - }) + async encode(data: UpdateProposalConfigData): Promise { + const config = await this.options.queryClient.fetchQuery( + daoProposalMultipleQueries.config(this.options.queryClient, { + chainId: this.proposalModule.dao.chainId, + contractAddress: this.proposalModule.address, + }) + ) + + const updateConfigMessage: ExecuteMsg = { + update_config: { + voting_strategy: { + single_choice: { + quorum: typePercentageToPercentageThreshold( + data.quorumType, + data.quorumPercentage + ), + }, }, - [proposalModuleConfig] - ) + max_voting_period: convertDurationWithUnitsToDuration( + data.votingDuration + ), + only_members_execute: data.onlyMembersExecute, + allow_revoting: data.allowRevoting, + // If veto is supported... + ...(this.proposalModule.version && + isFeatureSupportedByVersion( + Feature.Veto, + this.proposalModule.version + ) && { + veto: convertVetoConfigToCosmos(data.veto), + }), + // Pass through because we don't support changing them yet. + dao: config.dao, + close_proposal_on_execution_failure: + config.close_proposal_on_execution_failure, + min_voting_period: config.min_voting_period, + }, } - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - const isUpdateConfig = useMsgExecutesContract( - msg, - DAO_PROPOSAL_MULTIPLE_CONTRACT_NAMES, - { - update_config: { - allow_revoting: {}, - close_proposal_on_execution_failure: {}, - dao: {}, - max_voting_period: {}, - min_voting_period: {}, - only_members_execute: {}, - voting_strategy: { - single_choice: { - quorum: {}, + return makeExecuteSmartContractMessage({ + chainId: this.proposalModule.dao.chainId, + contractAddress: this.proposalModule.address, + sender: this.options.address, + msg: updateConfigMessage, + }) + } + + match([ + { + decodedMessage, + account: { chainId }, + }, + ]: ProcessedMessage[]): ActionMatch { + return ( + objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + update_config: { + allow_revoting: {}, + close_proposal_on_execution_failure: {}, + dao: {}, + max_voting_period: {}, + min_voting_period: {}, + only_members_execute: {}, + voting_strategy: { + single_choice: { + quorum: {}, + }, + }, }, }, }, - } - ) - - // Attempt to load cw1-whitelist admins if the vetoer is set. Will only - // succeed if the vetoer is a cw1-whitelist contract. Otherwise it returns - // undefined. - const cw1WhitelistAdminsLoadable = useRecoilValueLoadable( - isUpdateConfig && msg.wasm.execute.msg.update_config.veto - ? Cw1WhitelistSelectors.adminsIfCw1Whitelist({ - chainId, - contractAddress: msg.wasm.execute.msg.update_config.veto.vetoer, - }) - : constSelector(undefined) - ) - - if (!isUpdateConfig || cw1WhitelistAdminsLoadable.state !== 'hasValue') { - return { match: false } - } + }, + }) && + chainId === this.proposalModule.dao.chainId && + decodedMessage.wasm.execute.contract_addr === this.proposalModule.address + ) + } - const { - allow_revoting: allowRevoting, - only_members_execute: onlyMembersExecute, - max_voting_period, - voting_strategy: votingStrategy, - veto, - } = msg.wasm.execute.msg.update_config + async decode([ + { decodedMessage }, + ]: ProcessedMessage[]): Promise { + const config = decodedMessage.wasm.execute.msg.update_config - return { - match: true, - data: { - allowRevoting, - onlyMembersExecute, - votingDuration: convertDurationToDurationWithUnits(max_voting_period), - proposalDurationUnits: 'seconds', - veto: convertCosmosVetoConfigToVeto( - veto, - cw1WhitelistAdminsLoadable.valueMaybe() - ), - ...votingStrategyToProcessedQuorum(votingStrategy), - }, - } - } + // Attempt to load cw1-whitelist admins if the vetoer is set. Will only + // succeed if the vetoer is a cw1-whitelist contract. Otherwise it returns + // null. + const cw1WhitlistAdmins = config.veto + ? await this.options.queryClient.fetchQuery( + cw1WhitelistExtraQueries.adminsIfCw1Whitelist( + this.options.queryClient, + { + chainId: this.proposalModule.dao.chainId, + address: config.veto.vetoer, + } + ) + ) + : null return { - key: ActionKey.UpdateProposalConfig, - Icon: BallotDepositEmoji, - label: t('proposalModuleLabel.DaoProposalMultiple'), - // Not used. - description: '', - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, + onlyMembersExecute: config.only_members_execute, + votingDuration: convertDurationToDurationWithUnits( + config.max_voting_period + ), + allowRevoting: config.allow_revoting, + veto: convertCosmosVetoConfigToVeto(config.veto, cw1WhitlistAdmins), + ...votingStrategyToProcessedQuorum(config.voting_strategy), } } } diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/actions/index.ts b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/actions/index.ts index 9aec99040..26032b780 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/actions/index.ts +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/actions/index.ts @@ -1,2 +1,2 @@ -export { makeUpdatePreProposeConfigActionMaker } from './UpdatePreProposeConfig' -export { makeUpdateProposalConfigActionMaker } from './UpdateProposalConfig' +export { DaoProposalMultipleUpdatePreProposeConfigAction } from './UpdatePreProposeConfig' +export { DaoProposalMultipleUpdateConfigAction } from './UpdateProposalConfig' diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal.tsx b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal.tsx index de0e67a0e..e8822a291 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal.tsx +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal.tsx @@ -1,5 +1,4 @@ import { FlagOutlined, Timelapse } from '@mui/icons-material' -import { useCallback, useState } from 'react' import { useFormContext } from 'react-hook-form' import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' @@ -14,6 +13,7 @@ import { NewProposalTitleDescriptionHeader, NewProposal as StatelessNewProposal, NewProposalProps as StatelessNewProposalProps, + useActionsContext, useCachedLoadable, useChain, useDaoInfoContext, @@ -21,19 +21,18 @@ import { import { BaseNewProposalProps, IProposalModuleBase } from '@dao-dao/types' import { MAX_NUM_PROPOSAL_CHOICES, - convertActionsToMessages, convertExpirationToDate, dateToWdhms, + encodeActions, processError, } from '@dao-dao/utils' -import { useLoadedActionsAndCategories } from '../../../../../actions' +import { useActionEncodeContext } from '../../../../../actions' import { useMembership, useWallet } from '../../../../../hooks' import { makeGetProposalInfo } from '../../functions' import { NewProposalData, NewProposalForm, - SimulateProposal, UsePublishProposal, } from '../../types' import { useProcessQ } from '../hooks' @@ -69,8 +68,6 @@ export const NewProposal = ({ const { isMember = false, loading: membershipLoading } = useMembership() - const [loading, setLoading] = useState(false) - // Info about if the DAO is paused. This selector depends on blockHeight, // which is refreshed periodically, so use a loadable to avoid unnecessary // re-renders. @@ -94,26 +91,13 @@ export const NewProposal = ({ ) const { - simulateProposal: _simulateProposal, + simulateProposal, publishProposal, cannotProposeReason, depositUnsatisfied, simulationBypassExpiration, } = usePublishProposal() - const [simulating, setSimulating] = useState(false) - const simulateProposal: SimulateProposal = useCallback( - async (...params) => { - setSimulating(true) - try { - await _simulateProposal(...params) - } finally { - setSimulating(false) - } - }, - [_simulateProposal] - ) - const createProposal = useRecoilCallback( ({ snapshot }) => async (newProposalData: NewProposalData) => { @@ -128,7 +112,6 @@ export const NewProposal = ({ } const blocksPerYear = blocksPerYearLoadable.contents - setLoading(true) try { const { proposalNumber, proposalId } = await publishProposal( newProposalData, @@ -209,11 +192,9 @@ export const NewProposal = ({ }, } ) - // Don't stop loading indicator on success since we are navigating. } catch (err) { console.error(err) toast.error(processError(err)) - setLoading(false) } }, [ @@ -232,24 +213,35 @@ export const NewProposal = ({ ] ) - const { loadedActions } = useLoadedActionsAndCategories() + const { actionMap } = useActionsContext() + const encodeContext = useActionEncodeContext() const getProposalDataFromFormData: StatelessNewProposalProps< NewProposalForm, NewProposalData - >['getProposalDataFromFormData'] = ({ title, description, choices }) => ({ + >['getProposalDataFromFormData'] = async ({ + title, + description, + choices, + }) => ({ title, description, choices: { - options: choices.map((option) => ({ - title: option.title, - description: option.description, - // Type mismatch between Cosmos msgs and Secret Network Cosmos msgs. The - // contract execution will fail if the messages are invalid, so this is - // safe. The UI should ensure that the co rrect messages are used for - // the given chain anyways. - msgs: convertActionsToMessages(loadedActions, option.actionData) as any, - })), + options: await Promise.all( + choices.map(async (option) => ({ + title: option.title, + description: option.description, + // Type mismatch between Cosmos msgs and Secret Network Cosmos msgs. + // The contract execution will fail if the messages are invalid, so + // this is safe. The UI should ensure that the correct messages are + // used for the given chain anyways. + msgs: (await encodeActions({ + actionMap, + encodeContext, + data: option.actionData, + })) as any, + })) + ), }, }) @@ -283,7 +275,6 @@ export const NewProposal = ({ } isPaused={isPaused} isWalletConnecting={isWalletConnecting} - loading={loading || simulating} proposalTitle={proposalTitle} simulateProposal={simulateProposal} simulationBypassExpiration={simulationBypassExpiration} diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposalMain.tsx b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposalMain.tsx index c162abbd3..fb124250a 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposalMain.tsx +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposalMain.tsx @@ -2,7 +2,6 @@ import { Add, Block, Circle } from '@mui/icons-material' import { useFieldArray, useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' -import { useLoadedActionsAndCategories } from '../../../../../actions' import { SuspenseLoader } from '../../../../../components' import { MULTIPLE_CHOICE_OPTION_COLORS, @@ -18,7 +17,6 @@ export const NewProposalMain = () => { watch, formState: { errors }, } = useFormContext() - const { loadedActions, categories } = useLoadedActionsAndCategories() const { fields: multipleChoiceFields, @@ -41,11 +39,9 @@ export const NewProposalMain = () => { key={id} SuspenseLoader={SuspenseLoader} addOption={addOption} - categories={categories} control={control} descriptionFieldName={`choices.${index}.description`} errorsOption={errors?.choices?.[index]} - loadedActions={loadedActions} optionIndex={index} registerOption={register} removeOption={() => removeOption(index)} diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposalPreview.tsx b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposalPreview.tsx index 3f013774f..3484c471b 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposalPreview.tsx +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposalPreview.tsx @@ -3,9 +3,8 @@ import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { ProposalContentDisplay } from '@dao-dao/stateless' -import { convertActionsToMessages } from '@dao-dao/utils' -import { useLoadedActionsAndCategories } from '../../../../../actions' +import { useActionEncodeContext } from '../../../../../actions' import { EntityDisplay, SuspenseLoader } from '../../../../../components' import { useEntity, useWallet } from '../../../../../hooks' import { MULTIPLE_CHOICE_OPTION_COLORS } from '../../components/MultipleChoiceOptionEditor' @@ -15,8 +14,8 @@ import { NewProposalForm } from '../../types' export const NewProposalPreview = () => { const { t } = useTranslation() const { watch } = useFormContext() + const encodeContext = useActionEncodeContext() - const { loadedActions } = useLoadedActionsAndCategories() const { address: walletAddress = '' } = useWallet() const { entity } = useEntity(walletAddress) @@ -41,23 +40,17 @@ export const NewProposalPreview = () => { { ], }, }} - forceRaw + encodeContext={encodeContext} lastOption={false} + preview /> ))} {/* None of the above */}
} diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/selectors.ts b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/selectors.ts index acac3a8df..61bd798c2 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/selectors.ts +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/selectors.ts @@ -14,7 +14,6 @@ import { CheckedDepositInfo, ContractVersion, Duration, - ProposalStatusEnum, WithChainId, } from '@dao-dao/types' import { @@ -111,7 +110,7 @@ export const reverseProposalInfosSelector: ( id: `${proposalModulePrefix}${id}`, proposalNumber: id, timestamp: timestamps[index], - isOpen: status === ProposalStatusEnum.Open, + status, }) ) diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/components/MultipleChoiceOptionEditor.tsx b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/components/MultipleChoiceOptionEditor.tsx index f746d8c56..c5f4c6f2b 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/components/MultipleChoiceOptionEditor.tsx +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/components/MultipleChoiceOptionEditor.tsx @@ -21,11 +21,7 @@ import { TextAreaInput, TextInput, } from '@dao-dao/stateless' -import { - ActionCategoryWithLabel, - LoadedActions, - SuspenseLoaderProps, -} from '@dao-dao/types' +import { SuspenseLoaderProps } from '@dao-dao/types' import { validateRequired } from '@dao-dao/utils' import { MultipleChoiceOptionFormData, NewProposalForm } from '../types' @@ -40,10 +36,8 @@ export interface MultipleChoiceOptionEditorProps< registerOption: UseFormRegister optionIndex: number control: Control - categories: ActionCategoryWithLabel[] removeOption: () => void addOption: (value: Partial) => void - loadedActions: LoadedActions SuspenseLoader: ComponentType } @@ -56,10 +50,8 @@ export const MultipleChoiceOptionEditor = < errorsOption, registerOption, optionIndex, - categories, removeOption, addOption, - loadedActions, SuspenseLoader, }: MultipleChoiceOptionEditorProps) => { const { t } = useTranslation() @@ -163,8 +155,6 @@ export const MultipleChoiceOptionEditor = < SuspenseLoader={SuspenseLoader} actionDataErrors={errorsOption?.actionData} actionDataFieldName={`choices.${optionIndex}.actionData`} - categories={categories} - loadedActions={loadedActions} />
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 22e1868eb..0260bf272 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/components/MultipleChoiceOptionViewer.tsx +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/components/MultipleChoiceOptionViewer.tsx @@ -1,19 +1,23 @@ import { Check, DataObject } from '@mui/icons-material' import clsx from 'clsx' -import { ComponentType, useMemo, useState } from 'react' +import { ComponentType, useState } from 'react' import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' import { - ActionsRenderer, + ActionsMatchAndRender, Button, - CosmosMessageDisplay, DropdownIconButton, MarkdownRenderer, + RawActionsRenderer, Tooltip, } from '@dao-dao/stateless' -import { SuspenseLoaderProps } from '@dao-dao/types' -import { decodeRawDataForDisplay } from '@dao-dao/utils' +import { + ActionEncodeContext, + ActionKeyAndData, + ActionKeyAndDataNoId, + SuspenseLoaderProps, +} from '@dao-dao/types' import { MultipleChoiceOptionData } from '../types' @@ -23,26 +27,52 @@ export type MultipleChoiceOptionViewerProps = { // If undefined, no winner picked yet. winner?: boolean // Used when previewing to force raw JSON display. - forceRaw?: boolean - // Called when the user has viewed all action pages. - setSeenAllActionPages?: () => void + preview?: boolean SuspenseLoader: ComponentType -} +} & ( + | { + /** + * Force raw JSON display and use existing action data instead of matching + * and decoding messages from the choice data. + */ + preview: true + /** + * Encode context. + */ + encodeContext: ActionEncodeContext + /** + * Action keys and data to preview. + */ + actionKeysAndData: ActionKeyAndDataNoId[] + + onLoad?: never + } + | { + preview?: false + encodeContext?: never + actionKeysAndData?: never + /** + * Callback when all actions and data are loaded. + */ + onLoad?: (data: ActionKeyAndData[]) => void + } +) export const MultipleChoiceOptionViewer = ({ - data: { choice, actionData, decodedMessages, voteOption }, + data: { choice, voteOption }, lastOption, winner, - forceRaw, - setSeenAllActionPages, SuspenseLoader, + ...previewData }: MultipleChoiceOptionViewerProps) => { const { t } = useTranslation() const [showRaw, setShowRaw] = useState(false) const isNoneOption = choice.option_type === 'none' - const noMessages = decodedMessages.length === 0 + const noMessages = previewData.preview + ? previewData.actionKeysAndData.length === 0 + : choice.msgs.length === 0 const noContent = noMessages && !choice.description // Close none of the above and disallow expanding. @@ -55,11 +85,6 @@ export const MultipleChoiceOptionViewer = ({ ) const toggleExpanded = () => setExpanded((e) => !e) - const rawDecodedMessages = useMemo( - () => JSON.stringify(decodedMessages.map(decodeRawDataForDisplay), null, 2), - [decodedMessages] - ) - return (
{t('info.optionInert')}

- ) : (forceRaw === undefined && showRaw) || forceRaw ? ( - + ) : previewData.preview || showRaw ? ( + // If previewing, load raw from actions. + previewData.preview ? ( + + ) : ( + // Otherwise use messages that already exist. + + ) ) : ( - toast.success(t('info.copiedLinkToClipboard'))} - setSeenAllActionPages={setSeenAllActionPages} + onLoad={!previewData.preview && previewData.onLoad} /> )} - {forceRaw === undefined && decodedMessages.length > 0 && ( + {!previewData.preview && !noMessages && ( - {showRaw && } + {showRaw && }
) : (

{t('info.noProposalActions')}

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 84b21c03a..b16c2ac60 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/components/ProposalInnerContentDisplay.tsx +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/components/ProposalInnerContentDisplay.tsx @@ -1,18 +1,16 @@ import { DataObject } from '@mui/icons-material' -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' -import useDeepCompareEffect from 'use-deep-compare-effect' import { ActionCardLoader, - ActionsRenderer, + ActionsMatchAndRender, Button, - CosmosMessageDisplay, + RawActionsRenderer, useDaoInfoContext, } from '@dao-dao/stateless' import { - ActionAndData, ActionKeyAndData, BaseProposalInnerContentDisplayProps, ChainId, @@ -20,11 +18,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, - decodeRawDataForDisplay, - objectMatchesStructure, -} from '@dao-dao/utils' +import { decodeMessages, objectMatchesStructure } from '@dao-dao/utils' import { SuspenseLoader } from '../../../../components' import { useLoadingProposal } from '../hooks' @@ -52,9 +46,7 @@ export const ProposalInnerContentDisplay = ( const InnerProposalInnerContentDisplay = ({ setDuplicateFormData, - actionsForMatching, proposal, - setSeenAllActionPages, }: BaseProposalInnerContentDisplayProps & { proposal: Proposal | SingleChoiceProposal }) => { @@ -62,13 +54,9 @@ const InnerProposalInnerContentDisplay = ({ const [showRaw, setShowRaw] = useState(false) const { chainId, coreVersion } = useDaoInfoContext() - const { decodedMessages, rawDecodedMessages } = useMemo(() => { - let decodedMessages = decodeMessages(proposal.msgs) - const rawDecodedMessages = JSON.stringify( - decodedMessages.map(decodeRawDataForDisplay), - null, - 2 - ) + const actionMessagesToDisplay = useMemo(() => { + let messages = proposal.msgs + const decodedMessages = decodeMessages(messages) // Unwrap `timelock_proposal` execute in Neutron SubDAOs. try { @@ -112,9 +100,8 @@ const InnerProposalInnerContentDisplay = ({ }, }) ) { - decodedMessages = decodeMessages( + messages = innerDecoded[0].wasm.execute.msg.execute_timelocked_msgs.msgs - ) } } } @@ -122,73 +109,25 @@ const InnerProposalInnerContentDisplay = ({ console.error('Neutron timelock_proposal unwrap error', error) } - return { - decodedMessages, - rawDecodedMessages, - } + return messages }, [chainId, coreVersion, proposal.msgs]) - // If no msgs, set seen all action pages to true so that the user can vote. - const [markedSeen, setMarkedSeen] = useState(false) - useEffect(() => { - if (markedSeen) { - return - } - - if (setSeenAllActionPages && !decodedMessages.length) { - setSeenAllActionPages() - setMarkedSeen(true) - } - }, [decodedMessages.length, markedSeen, setSeenAllActionPages]) - - // Call relevant action hooks in the same order every time. - const actionData: ActionAndData[] = decodedMessages.map((message) => { - const actionMatch = actionsForMatching - .map((action) => ({ - action, - ...action.useDecodedCosmosMsg(message), + const onLoad = + setDuplicateFormData && + ((data: ActionKeyAndData[]) => + setDuplicateFormData({ + title: proposal.title, + description: proposal.description, + actionData: data, })) - .find(({ match }) => match) - - // There should always be a match since custom matches all. This should - // never happen as long as the Custom action exists. - if (!actionMatch?.match) { - throw new Error(t('error.loadingData')) - } - - return { - action: actionMatch.action, - data: actionMatch.data, - } - }) - - const actionKeyAndData = actionData.map( - ({ action, data }, index): ActionKeyAndData => ({ - _id: index.toString(), - actionKey: action.key, - data, - }) - ) - useDeepCompareEffect(() => { - setDuplicateFormData?.({ - title: proposal.title, - description: proposal.description, - actionData: actionKeyAndData, - }) - }, [ - actionKeyAndData, - proposal.title, - proposal.description, - setDuplicateFormData, - ]) - return decodedMessages?.length ? ( + return actionMessagesToDisplay.length ? (
- toast.success(t('info.copiedLinkToClipboard'))} - setSeenAllActionPages={setSeenAllActionPages} + onLoad={onLoad} /> - {showRaw && } + {showRaw && }
) : (

{t('info.noProposalActions')}

diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/components/ProposalStatusAndInfo.tsx b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/components/ProposalStatusAndInfo.tsx index 80496ce98..a5290e495 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/components/ProposalStatusAndInfo.tsx +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/components/ProposalStatusAndInfo.tsx @@ -448,13 +448,9 @@ const InnerProposalStatusAndInfo = ({ const Voter = useCallback( (props: ComponentProps['Voter']>) => ( - + ), - [voter.onVoteSuccess, voter.seenAllActionPages] + [voter.onVoteSuccess] ) return ( diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/index.tsx b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/index.tsx index b168168e6..7501c680f 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/index.tsx +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/index.tsx @@ -10,11 +10,11 @@ import { } from '@dao-dao/utils' import { + DaoProposalSingleUpdatePreProposeConfigAction, + DaoProposalSingleV1UpdateConfigAction, + DaoProposalSingleV2UpdateConfigAction, NewProposal, depositInfoSelector as makeDepositInfoSelector, - makeUpdatePreProposeSingleConfigActionMaker, - makeUpdateProposalConfigV1ActionMaker, - makeUpdateProposalConfigV2ActionMaker, makeUsePublishProposal, maxVotingPeriodSelector, proposalCountSelector, @@ -83,11 +83,15 @@ export const DaoProposalSingleAdapter: ProposalModuleAdapter< actionData: [], }), newProposalFormTitleKey: 'title', - updateConfigActionMaker: (proposalModule.version === ContractVersion.V1 - ? makeUpdateProposalConfigV1ActionMaker - : makeUpdateProposalConfigV2ActionMaker)(proposalModule), - updatePreProposeConfigActionMaker: - makeUpdatePreProposeSingleConfigActionMaker(proposalModule), + updateConfigActionMaker: (options) => + new (proposalModule.version === ContractVersion.V1 + ? DaoProposalSingleV1UpdateConfigAction + : DaoProposalSingleV2UpdateConfigAction)(options, proposalModule), + updatePreProposeConfigActionMaker: (options) => + new DaoProposalSingleUpdatePreProposeConfigAction( + options, + proposalModule + ), }, // Selectors diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/types.ts b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/types.ts index aeab3fa1e..efb755665 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/types.ts +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/types.ts @@ -1,5 +1,4 @@ import { - ActionAndData, ActionKeyAndData, DepositInfoSelector, IProposalModuleBase, @@ -119,8 +118,3 @@ export type ProposalWithMetadata = (Proposal | SingleChoiceProposal) & { // overrule proposal and its DAO created once executed and thus timelocked. neutronTimelockOverrule?: NeutronTimelockOverrule } - -export type MessagesWithActionData = { - decodedMessages: any[] - actionData: ActionAndData[] -} diff --git a/packages/stateful/proposal-module-adapter/core.ts b/packages/stateful/proposal-module-adapter/core.ts index 5137822dd..91a4a3484 100644 --- a/packages/stateful/proposal-module-adapter/core.ts +++ b/packages/stateful/proposal-module-adapter/core.ts @@ -7,6 +7,7 @@ import { IProposalModuleContext, ProposalModuleAdapter, } from '@dao-dao/types' +import { extractProposalInfo } from '@dao-dao/utils' import { DaoProposalMultipleAdapter, @@ -74,23 +75,17 @@ export const matchAndLoadAdapter = ( dao: IDaoBase, proposalId: string ): IProposalModuleContext => { - // Prefix is alphabetical, followed by numeric prop number. If there is an - // asterisk between the prefix and the prop number, this is a pre-propose - // proposal. Allow the prefix to be empty for backwards compatibility. Default - // to first proposal module if no alphabetical prefix. - const proposalIdParts = proposalId.match(/^([A-Z]*)(\*)?(\d+)$/) - if (proposalIdParts?.length !== 4) { - throw new ProposalModuleAdapterError('Failed to parse proposal ID.') - } - - // Undefined if matching group doesn't exist, i.e. no prefix exists. - const proposalPrefix = proposalIdParts[1] ?? '' - const isPreProposeApprovalProposal = proposalIdParts[2] === '*' - const proposalNumber = Number(proposalIdParts[3]) - - if (isNaN(proposalNumber)) { + let proposalPrefix: string + let proposalNumber: number + let isPreProposeApprovalProposal: boolean + try { + const info = extractProposalInfo(proposalId) + proposalPrefix = info.prefix + proposalNumber = info.proposalNumber + isPreProposeApprovalProposal = info.isPreProposeApprovalProposal + } catch (err) { throw new ProposalModuleAdapterError( - `Invalid proposal number "${proposalNumber}".` + err instanceof Error ? err.message : 'Failed to parse proposal ID.' ) } diff --git a/packages/stateful/queries/dao.ts b/packages/stateful/queries/dao.ts index 59c5d278f..458dc6c25 100644 --- a/packages/stateful/queries/dao.ts +++ b/packages/stateful/queries/dao.ts @@ -15,7 +15,7 @@ import { DaoSource, Feature, InfoResponse, - ProposalModule, + ProposalModuleInfo, } from '@dao-dao/types' import { getDaoInfoForChainId, @@ -36,7 +36,7 @@ import { fetchProposalModules } from '../utils' export const fetchDaoProposalModules = async ( queryClient: QueryClient, { chainId, coreAddress }: DaoSource -): Promise => { +): Promise => { const coreVersion = parseContractVersion( ( await queryClient.fetchQuery( @@ -99,7 +99,7 @@ export const fetchDaoInfo = async ( }) ), queryClient.fetchQuery( - chainQueries.wasmContractAdmin({ + contractQueries.admin({ chainId, address: coreAddress, }) diff --git a/packages/stateful/recoil/selectors/dao.ts b/packages/stateful/recoil/selectors/dao.ts index 91e02627b..399a7026e 100644 --- a/packages/stateful/recoil/selectors/dao.ts +++ b/packages/stateful/recoil/selectors/dao.ts @@ -26,7 +26,7 @@ import { DaoWithDropdownVetoableProposalList, DaoWithVetoableProposals, IndexerDaoWithVetoableProposals, - ProposalModule, + ProposalModuleInfo, StatefulProposalLineProps, WithChainId, } from '@dao-dao/types' @@ -41,7 +41,7 @@ import { matchAdapter as matchVotingModuleAdapter } from '../../voting-module-ad export const followingDaosWithProposalModulesSelector = selectorFamily< (DaoSource & { - proposalModules: ProposalModule[] + proposalModules: ProposalModuleInfo[] })[], { walletPublicKey: string @@ -74,7 +74,7 @@ export const followingDaosWithProposalModulesSelector = selectorFamily< }) export const daoCoreProposalModulesSelector = selectorFamily< - ProposalModule[], + ProposalModuleInfo[], WithChainId<{ coreAddress: string }> >({ key: 'daoCoreProposalModules', diff --git a/packages/stateful/utils/fetchProposalModules.ts b/packages/stateful/utils/fetchProposalModules.ts index 7fa451a7a..ee5a78424 100644 --- a/packages/stateful/utils/fetchProposalModules.ts +++ b/packages/stateful/utils/fetchProposalModules.ts @@ -4,13 +4,16 @@ import { CwCoreV1QueryClient, DaoDaoCoreQueryClient, } from '@dao-dao/state/contracts' -import { indexerQueries } from '@dao-dao/state/query' +import { contractQueries, indexerQueries } from '@dao-dao/state/query' import { ContractVersion, - ProposalModule, + ProposalModuleInfo, ProposalModuleType, } from '@dao-dao/types' -import { InfoResponse } from '@dao-dao/types/contracts/common' +import { + ContractVersionInfo, + InfoResponse, +} from '@dao-dao/types/contracts/common' import { ProposalModuleWithInfo } from '@dao-dao/types/contracts/DaoDaoCore' import { DaoProposalMultipleAdapterId, @@ -29,7 +32,7 @@ export const fetchProposalModules = async ( coreVersion: ContractVersion, // If already fetched (from indexer), use that. activeProposalModules?: ProposalModuleWithInfo[] -): Promise => { +): Promise => { // Try indexer first. if (!activeProposalModules) { try { @@ -54,60 +57,94 @@ export const fetchProposalModules = async ( ) } - const proposalModules: ProposalModule[] = await Promise.all( - activeProposalModules.map(async ({ info, address, prefix }) => { - const version = - (info && parseContractVersion(info.version)) ?? ContractVersion.Unknown - - // Get adapter for this contract. - const adapter = info && matchAdapter(info.contract) - - // Get proposal module type from adapter. - const type: ProposalModuleType = - adapter?.id === DaoProposalSingleAdapterId - ? ProposalModuleType.Single - : adapter?.id === DaoProposalMultipleAdapterId - ? ProposalModuleType.Multiple - : ProposalModuleType.Other - - const [prePropose, veto] = await Promise.allSettled([ - // Get pre-propose address if exists. - adapter?.functions.fetchPrePropose?.( - queryClient, - chainId, - address, - version - ), - // Get veto config if exists. - adapter?.functions.fetchVetoConfig?.(chainId, address, version), - ]) - - return { + return await Promise.all( + activeProposalModules.map(async ({ info, address, prefix }) => + fetchProposalModule({ + queryClient, + chainId, address, prefix, - contractName: info?.contract || '', - version, - prePropose: - (prePropose.status === 'fulfilled' && prePropose.value) || null, - ...(type !== ProposalModuleType.Other - ? { - type, - config: { - veto: (veto.status === 'fulfilled' && veto.value) || null, - }, - } - : { - type, - }), - } - }) + info, + }) + ) ) +} - return proposalModules +export const fetchProposalModule = async ({ + queryClient, + chainId, + address, + prefix, + info, +}: { + queryClient: QueryClient + chainId: string + address: string + prefix: string + /** + * If not provided, it will be fetched. + */ + info?: ContractVersionInfo +}): Promise => { + // If no info, fetch it. + if (!info) { + info = ( + await queryClient.fetchQuery( + contractQueries.info(queryClient, { + chainId, + address, + }) + ) + ).info + } + + const version = + (info && parseContractVersion(info.version)) ?? ContractVersion.Unknown + + // Get adapter for this contract. + const adapter = info && matchAdapter(info.contract) + + // Get proposal module type from adapter. + const type: ProposalModuleType = + adapter?.id === DaoProposalSingleAdapterId + ? ProposalModuleType.Single + : adapter?.id === DaoProposalMultipleAdapterId + ? ProposalModuleType.Multiple + : ProposalModuleType.Other + + const [prePropose, veto] = await Promise.allSettled([ + // Get pre-propose address if exists. + adapter?.functions.fetchPrePropose?.( + queryClient, + chainId, + address, + version + ), + // Get veto config if exists. + adapter?.functions.fetchVetoConfig?.(chainId, address, version), + ]) + + return { + address, + prefix, + contractName: info?.contract || '', + version, + prePropose: (prePropose.status === 'fulfilled' && prePropose.value) || null, + ...(type !== ProposalModuleType.Other + ? { + type, + config: { + veto: (veto.status === 'fulfilled' && veto.value) || null, + }, + } + : { + type, + }), + } } const LIMIT = 10 -export const fetchProposalModulesWithInfoFromChain = async ( +const fetchProposalModulesWithInfoFromChain = async ( chainId: string, coreAddress: string, coreVersion: ContractVersion diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/actions/Mint/MintComponent.stories.tsx b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/actions/Mint/MintComponent.stories.tsx index 340a486ab..6c557d73f 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/actions/Mint/MintComponent.stories.tsx +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/actions/Mint/MintComponent.stories.tsx @@ -4,8 +4,7 @@ import { AddressInput } from '@dao-dao/stateless' import { CHAIN_ID, makeReactHookFormDecorator } from '@dao-dao/storybook' import { TokenType } from '@dao-dao/types' -import { MintData } from '.' -import { MintComponent } from './MintComponent' +import { MintComponent, MintData } from './MintComponent' export default { title: diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/actions/Mint/MintComponent.tsx b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/actions/Mint/MintComponent.tsx index 8d782dc0d..b17506f45 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/actions/Mint/MintComponent.tsx +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/actions/Mint/MintComponent.tsx @@ -9,6 +9,7 @@ import { useFormContext } from 'react-hook-form' import { InputErrorMessage, NumberInput, + useActionOptions, useDetectWrap, } from '@dao-dao/stateless' import { @@ -22,9 +23,12 @@ import { validateRequired, } from '@dao-dao/utils' -import { useActionOptions } from '../../../../../actions' +export type MintData = { + to: string + amount: number +} -export interface MintOptions { +export type MintOptions = { govToken: GenericToken // Used to display the profile of the address receiving minted tokens. AddressInput: ComponentType diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/actions/Mint/index.tsx b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/actions/Mint/index.tsx index 5a21f8221..edba8cf84 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/actions/Mint/index.tsx +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/actions/Mint/index.tsx @@ -1,87 +1,26 @@ -import { useCallback, useMemo } from 'react' - -import { HerbEmoji } from '@dao-dao/stateless' +import { ActionBase, HerbEmoji } from '@dao-dao/stateless' +import { GenericToken, TokenType, UnifiedCosmosMsg } from '@dao-dao/types' import { ActionComponent, + ActionContextType, ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, + ActionMatch, + ActionOptions, + ProcessedMessage, } from '@dao-dao/types/actions' import { convertDenomToMicroDenomStringWithDecimals, convertMicroDenomToDenomWithDecimals, - makeWasmMessage, + makeExecuteSmartContractMessage, + objectMatchesStructure, } from '@dao-dao/utils' import { AddressInput } from '../../../../../components' import { useGovernanceTokenInfo } from '../../hooks' -import { MintComponent as StatelessMintComponent } from './MintComponent' - -export interface MintData { - to: string - amount: number -} - -const useTransformToCosmos: UseTransformToCosmos = () => { - const { governanceToken } = useGovernanceTokenInfo() - - return useCallback( - (data: MintData) => - makeWasmMessage({ - wasm: { - execute: { - contract_addr: governanceToken.denomOrAddress, - msg: { - mint: { - amount: convertDenomToMicroDenomStringWithDecimals( - data.amount, - governanceToken.decimals - ), - recipient: data.to, - }, - }, - funds: [], - }, - }, - }), - [governanceToken] - ) -} - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - const { governanceToken } = useGovernanceTokenInfo() - - return useMemo(() => { - if ( - 'wasm' in msg && - 'execute' in msg.wasm && - 'contract_addr' in msg.wasm.execute && - // Mint action only supports minting our own governance token. Let - // custom action handle the rest of the mint messages for now. - msg.wasm.execute.contract_addr === governanceToken.denomOrAddress && - 'mint' in msg.wasm.execute.msg && - 'amount' in msg.wasm.execute.msg.mint && - 'recipient' in msg.wasm.execute.msg.mint - ) { - return { - match: true, - data: { - to: msg.wasm.execute.msg.mint.recipient, - amount: convertMicroDenomToDenomWithDecimals( - msg.wasm.execute.msg.mint.amount, - governanceToken.decimals - ), - }, - } - } - - return { match: false } - }, [governanceToken, msg]) -} +import { + MintData, + MintComponent as StatelessMintComponent, +} from './MintComponent' const Component: ActionComponent = (props) => { const { governanceToken } = useGovernanceTokenInfo() @@ -97,20 +36,88 @@ const Component: ActionComponent = (props) => { ) } -export const makeMintAction: ActionMaker = ({ t, address }) => { - const useDefaults: UseDefaults = () => ({ - to: address, - amount: 1, - }) +export class MintAction extends ActionBase { + public readonly key = ActionKey.Mint + public readonly Component = Component + + private governanceToken?: GenericToken + + constructor(options: ActionOptions) { + super(options, { + Icon: HerbEmoji, + label: options.t('title.mint'), + description: options.t('info.mintActionDescription'), + }) - return { - key: ActionKey.Mint, - Icon: HerbEmoji, - label: t('title.mint'), - description: t('info.mintActionDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, + this.defaults = { + to: options.address, + amount: 1, + } + } + + async setup() { + // Type-check. + if ( + this.options.context.type !== ActionContextType.Dao || + !this.options.context.dao.votingModule.getGovernanceTokenQuery + ) { + throw new Error('Invalid context for mint action') + } + + this.governanceToken = await this.options.queryClient.fetchQuery( + this.options.context.dao.votingModule.getGovernanceTokenQuery() + ) + } + + encode({ to, amount }: MintData): UnifiedCosmosMsg { + if (!this.governanceToken || this.governanceToken.type !== TokenType.Cw20) { + throw new Error('Action not ready') + } + + return makeExecuteSmartContractMessage({ + chainId: this.options.chain.chain_id, + sender: this.options.address, + contractAddress: this.governanceToken.denomOrAddress, + msg: { + mint: { + amount: convertDenomToMicroDenomStringWithDecimals( + amount, + this.governanceToken.decimals + ), + recipient: to, + }, + }, + }) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + mint: { + amount: {}, + recipient: {}, + }, + }, + }, + }, + }) + } + + decode([{ decodedMessage }]: ProcessedMessage[]): MintData { + if (!this.governanceToken || this.governanceToken.type !== TokenType.Cw20) { + throw new Error('Action not ready') + } + + return { + to: decodedMessage.wasm.execute.msg.mint.recipient, + amount: convertMicroDenomToDenomWithDecimals( + decodedMessage.wasm.execute.msg.mint.amount, + this.governanceToken.decimals + ), + } } } diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/actions/UpdateStakingConfig/index.ts b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/actions/UpdateStakingConfig/index.ts index bddfdf908..442be4cb3 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/actions/UpdateStakingConfig/index.ts +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/actions/UpdateStakingConfig/index.ts @@ -1,113 +1,120 @@ -import { useCallback } from 'react' - -import { GearEmoji } from '@dao-dao/stateless' -import { DurationUnits } from '@dao-dao/types' import { + cw20StakeQueries, + daoVotingCw20StakedQueries, +} from '@dao-dao/state/query' +import { ActionBase, GearEmoji } from '@dao-dao/stateless' +import { DurationUnits, UnifiedCosmosMsg } from '@dao-dao/types' +import { + ActionContextType, ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, + ActionMatch, + ActionOptions, + ProcessedMessage, } from '@dao-dao/types/actions' import { convertDurationToDurationWithUnits, convertDurationWithUnitsToDuration, - makeWasmMessage, + makeExecuteSmartContractMessage, objectMatchesStructure, } from '@dao-dao/utils' -import { useStakingInfo } from '../../hooks' import { UpdateStakingConfigComponent as Component, UpdateStakingConfigData, } from './Component' -const useDefaults: UseDefaults = () => { - const { unstakingDuration } = useStakingInfo() +export class UpdateStakingConfigAction extends ActionBase { + public readonly key = ActionKey.UpdateStakingConfig + public readonly Component = Component - return { - unstakingDurationEnabled: !!unstakingDuration, - unstakingDuration: unstakingDuration - ? convertDurationToDurationWithUnits(unstakingDuration) - : { - value: 2, - units: DurationUnits.Weeks, - }, + private stakingContractAddress?: string + + constructor(options: ActionOptions) { + super(options, { + Icon: GearEmoji, + label: options.t('title.updateStakingConfig'), + description: options.t('info.updateStakingConfigDescription'), + }) } -} -const useTransformToCosmos: UseTransformToCosmos< - UpdateStakingConfigData -> = () => { - const { stakingContractAddress } = useStakingInfo() + async setup() { + // Type-check. + if (this.options.context.type !== ActionContextType.Dao) { + throw new Error('Invalid context for update staking config action') + } + + this.stakingContractAddress = await this.options.queryClient.fetchQuery( + daoVotingCw20StakedQueries.stakingContract(this.options.queryClient, { + chainId: this.options.chain.chain_id, + contractAddress: this.options.context.dao.votingModule.address, + }) + ) - return useCallback( - ({ unstakingDurationEnabled, unstakingDuration }) => - makeWasmMessage({ + const { unstaking_duration } = await this.options.queryClient.fetchQuery( + cw20StakeQueries.getConfig(this.options.queryClient, { + chainId: this.options.chain.chain_id, + contractAddress: this.stakingContractAddress, + }) + ) + + this.defaults = { + unstakingDurationEnabled: !!unstaking_duration, + unstakingDuration: unstaking_duration + ? convertDurationToDurationWithUnits(unstaking_duration) + : { + value: 2, + units: DurationUnits.Weeks, + }, + } + } + + encode({ + unstakingDurationEnabled, + unstakingDuration, + }: UpdateStakingConfigData): UnifiedCosmosMsg { + return makeExecuteSmartContractMessage({ + chainId: this.options.chain.chain_id, + sender: this.options.address, + contractAddress: this.stakingContractAddress!, + msg: { + update_config: { + duration: unstakingDurationEnabled + ? convertDurationWithUnitsToDuration(unstakingDuration) + : null, + }, + }, + }) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return ( + objectMatchesStructure(decodedMessage, { wasm: { execute: { - contract_addr: stakingContractAddress, - funds: [], + contract_addr: {}, + funds: {}, msg: { - update_config: { - duration: unstakingDurationEnabled - ? convertDurationWithUnitsToDuration(unstakingDuration) - : null, - }, + update_config: {}, }, }, }, - }), - [stakingContractAddress] - ) -} - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - const { stakingContractAddress } = useStakingInfo() + }) && + decodedMessage.wasm.execute.contract_addr === this.stakingContractAddress + ) + } - return objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - update_config: {}, - }, - }, - }, - }) && msg.wasm.execute.contract_addr === stakingContractAddress - ? { - match: true, - data: { - unstakingDurationEnabled: - !!msg.wasm.execute.msg.update_config.duration, - unstakingDuration: msg.wasm.execute.msg.update_config.duration - ? convertDurationToDurationWithUnits( - msg.wasm.execute.msg.update_config.duration - ) - : { - value: 2, - units: DurationUnits.Weeks, - }, - }, - } - : { - match: false, - } + decode([{ decodedMessage }]: ProcessedMessage[]): UpdateStakingConfigData { + return { + unstakingDurationEnabled: + !!decodedMessage.wasm.execute.msg.update_config.duration, + unstakingDuration: decodedMessage.wasm.execute.msg.update_config.duration + ? convertDurationToDurationWithUnits( + decodedMessage.wasm.execute.msg.update_config.duration + ) + : { + value: 2, + units: DurationUnits.Weeks, + }, + } + } } - -export const makeUpdateStakingConfigAction: ActionMaker< - UpdateStakingConfigData -> = ({ t }) => ({ - key: ActionKey.UpdateStakingConfig, - Icon: GearEmoji, - label: t('title.updateStakingConfig'), - description: t('info.updateStakingConfigDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - notReusable: true, -}) diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/hooks/index.ts b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/hooks/index.ts index 4eceea77e..88f146894 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/hooks/index.ts +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/hooks/index.ts @@ -1,4 +1,3 @@ -export * from './useCommonGovernanceTokenInfo' export * from './useGovernanceTokenInfo' export * from './useMainDaoInfoCards' export * from './useStakingInfo' diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/hooks/useCommonGovernanceTokenInfo.ts b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/hooks/useCommonGovernanceTokenInfo.ts deleted file mode 100644 index e59c033d9..000000000 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/hooks/useCommonGovernanceTokenInfo.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useRecoilValue } from 'recoil' - -import { - DaoVotingCw20StakedSelectors, - genericTokenSelector, -} from '@dao-dao/state/recoil' -import { GenericToken, TokenType } from '@dao-dao/types' - -import { useVotingModuleAdapterOptions } from '../../../react/context' - -export const useCommonGovernanceTokenInfo = (): GenericToken => { - const { chainId, votingModuleAddress } = useVotingModuleAdapterOptions() - - const governanceTokenAddress = useRecoilValue( - DaoVotingCw20StakedSelectors.tokenContractSelector({ - chainId, - contractAddress: votingModuleAddress, - params: [], - }) - ) - - const eitherTokenInfo = useRecoilValue( - genericTokenSelector({ - chainId, - type: TokenType.Cw20, - denomOrAddress: governanceTokenAddress, - }) - ) - - return eitherTokenInfo -} diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/index.ts b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/index.ts index d985ccf9c..d3bf9f7bf 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/index.ts +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw20Staked/index.ts @@ -3,6 +3,7 @@ import { PeopleAltOutlined, PeopleAltRounded } from '@mui/icons-material' import { MainDaoInfoCardsTokenLoader } from '@dao-dao/stateless' import { ActionCategoryKey, + ActionKey, DaoTabId, VotingModuleAdapter, } from '@dao-dao/types' @@ -12,13 +13,9 @@ import { isSecretNetwork, } from '@dao-dao/utils' -import { makeMintAction, makeUpdateStakingConfigAction } from './actions' +import { MintAction, UpdateStakingConfigAction } from './actions' import { MembersTab, ProfileCardMemberInfo, StakingModal } from './components' -import { - useCommonGovernanceTokenInfo, - useMainDaoInfoCards, - useVotingModuleRelevantAddresses, -} from './hooks' +import { useMainDaoInfoCards, useVotingModuleRelevantAddresses } from './hooks' export const DaoVotingCw20StakedAdapter: VotingModuleAdapter = { id: DaoVotingCw20StakedAdapterId, @@ -29,7 +26,6 @@ export const DaoVotingCw20StakedAdapter: VotingModuleAdapter = { hooks: { useMainDaoInfoCards, useVotingModuleRelevantAddresses, - useCommonGovernanceTokenInfo, }, // Components @@ -54,13 +50,16 @@ export const DaoVotingCw20StakedAdapter: VotingModuleAdapter = { // Functions fields: { - actionCategoryMakers: [ - () => ({ + actions: { + actions: [MintAction, UpdateStakingConfigAction], + categoryMakers: [ // Add to DAO Governance category. - key: ActionCategoryKey.DaoGovernance, - actionMakers: [makeMintAction, makeUpdateStakingConfigAction], - }), - ], + () => ({ + key: ActionCategoryKey.DaoGovernance, + actionKeys: [ActionKey.Mint, ActionKey.UpdateStakingConfig], + }), + ], + }, }, }), } diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw4/actions/ManageMembers/Component.tsx b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw4/actions/ManageMembers/Component.tsx index 8b1d6581b..01a0e5a77 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw4/actions/ManageMembers/Component.tsx +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw4/actions/ManageMembers/Component.tsx @@ -17,6 +17,7 @@ import { InputLabel, Loader, NumberInput, + useActionOptions, useDetectWrap, } from '@dao-dao/stateless' import { @@ -32,8 +33,6 @@ import { validateRequired, } from '@dao-dao/utils' -import { useActionOptions } from '../../../../../actions' - export interface ManageMembersData { toAdd: Member[] toRemove: string[] diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw4/actions/ManageMembers/index.tsx b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw4/actions/ManageMembers/index.tsx index 8843d5430..686aa7bb5 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw4/actions/ManageMembers/index.tsx +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw4/actions/ManageMembers/index.tsx @@ -1,17 +1,20 @@ -import { useCallback } from 'react' - -import { PeopleEmoji } from '@dao-dao/stateless' +import { daoVotingCw4Queries } from '@dao-dao/state/query' +import { ActionBase, PeopleEmoji, useActionOptions } from '@dao-dao/stateless' +import { UnifiedCosmosMsg } from '@dao-dao/types' import { ActionComponent, + ActionContextType, ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, + ActionMatch, + ActionOptions, + ProcessedMessage, } from '@dao-dao/types/actions' -import { makeWasmMessage } from '@dao-dao/utils' +import { + makeExecuteSmartContractMessage, + objectMatchesStructure, +} from '@dao-dao/utils' -import { useActionOptions } from '../../../../../actions' +import { Cw4VotingModule } from '../../../../../clients' import { AddressInput, EntityDisplay } from '../../../../../components' import { useLoadingVotingModule } from '../../hooks/useLoadingVotingModule' import { @@ -19,11 +22,6 @@ import { ManageMembersComponent as StatelessManageMembersComponent, } from './Component' -const useDefaults: UseDefaults = (): ManageMembersData => ({ - toAdd: [], - toRemove: [], -}) - const Component: ActionComponent = (props) => { const { address } = useActionOptions() @@ -49,86 +47,84 @@ const Component: ActionComponent = (props) => { ) } -export const makeManageMembersAction: ActionMaker = ({ - t, - address, -}) => { - const useTransformToCosmos: UseTransformToCosmos = () => { - const votingModule = useLoadingVotingModule(address) - const cw4GroupAddress = - votingModule.loading || votingModule.errored - ? undefined - : votingModule.data.cw4GroupAddress - - return useCallback( - ({ toAdd, toRemove }) => { - if (!cw4GroupAddress) { - throw new Error(t('error.loadingData')) - } - - return makeWasmMessage({ - wasm: { - execute: { - contract_addr: cw4GroupAddress, - funds: [], - msg: { - update_members: { - add: toAdd, - remove: toRemove, - }, - }, - }, - }, - }) - }, - [cw4GroupAddress] - ) +export class ManageMembersAction extends ActionBase { + public readonly key = ActionKey.ManageMembers + public readonly Component = Component + + protected _defaults: ManageMembersData = { + toAdd: [], + toRemove: [], } - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - const votingModule = useLoadingVotingModule(address) - const cw4GroupAddress = - votingModule.loading || votingModule.errored - ? undefined - : votingModule.data.cw4GroupAddress - - if ( - cw4GroupAddress && - 'wasm' in msg && - 'execute' in msg.wasm && - 'contract_addr' in msg.wasm.execute && - msg.wasm.execute.contract_addr === cw4GroupAddress && - 'update_members' in msg.wasm.execute.msg && - 'add' in msg.wasm.execute.msg.update_members && - 'remove' in msg.wasm.execute.msg.update_members - ) { - return { - match: true, - data: { - toAdd: msg.wasm.execute.msg.update_members.add, - toRemove: msg.wasm.execute.msg.update_members.remove, - }, - } + private votingModule: Cw4VotingModule + private cw4GroupAddress = '' + + constructor(options: ActionOptions) { + if (options.context.type !== ActionContextType.Dao) { + throw new Error('Not DAO context') } - return { - match: false, + if (!(options.context.dao.votingModule instanceof Cw4VotingModule)) { + throw new Error('Not a CW4 voting module') } + + super(options, { + Icon: PeopleEmoji, + label: options.t('title.manageMembers'), + description: options.t('info.manageMembersActionDescription'), + // Show at the top. + listOrder: 1, + }) + + this.votingModule = options.context.dao.votingModule } - return { - key: ActionKey.ManageMembers, - Icon: PeopleEmoji, - label: t('title.manageMembers'), - description: t('info.manageMembersActionDescription'), - notReusable: true, - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - // Show at the top. - order: 1, + async setup() { + this.cw4GroupAddress = await this.options.queryClient.fetchQuery( + daoVotingCw4Queries.groupContract(this.options.queryClient, { + chainId: this.votingModule.dao.chainId, + contractAddress: this.votingModule.address, + }) + ) + } + + encode({ toAdd, toRemove }: ManageMembersData): UnifiedCosmosMsg { + if (!this.cw4GroupAddress) { + throw new Error('Manage members action not initialized') + } + + return makeExecuteSmartContractMessage({ + chainId: this.options.chain.chain_id, + sender: this.options.address, + contractAddress: this.cw4GroupAddress, + msg: { + update_members: { + add: toAdd, + remove: toRemove, + }, + }, + }) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + msg: { + update_members: { + add: {}, + remove: {}, + }, + }, + }, + }, + }) + } + + decode([{ decodedMessage }]: ProcessedMessage[]): ManageMembersData { + return { + toAdd: decodedMessage.wasm.execute.msg.update_members.add, + toRemove: decodedMessage.wasm.execute.msg.update_members.remove, + } } } diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw4/index.ts b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw4/index.ts index bc62fc829..506aadce3 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw4/index.ts +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw4/index.ts @@ -2,6 +2,7 @@ import { PeopleAltOutlined, PeopleAltRounded } from '@mui/icons-material' import { ActionCategoryKey, + ActionKey, DaoTabId, VotingModuleAdapter, } from '@dao-dao/types' @@ -11,7 +12,7 @@ import { isSecretNetwork, } from '@dao-dao/utils' -import { makeManageMembersAction } from './actions' +import { ManageMembersAction } from './actions' import { MainDaoInfoCardsLoader, MembersTab, @@ -49,15 +50,18 @@ export const DaoVotingCw4Adapter: VotingModuleAdapter = { ProfileCardMemberInfo, }, - // Functions + // Fields fields: { - actionCategoryMakers: [ - () => ({ + actions: { + actions: [ManageMembersAction], + categoryMakers: [ // Add to DAO Governance category. - key: ActionCategoryKey.DaoGovernance, - actionMakers: [makeManageMembersAction], - }), - ], + () => ({ + key: ActionCategoryKey.DaoGovernance, + actionKeys: [ActionKey.ManageMembers], + }), + ], + }, }, }), } diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw721Staked/actions/UpdateStakingConfig/index.ts b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw721Staked/actions/UpdateStakingConfig/index.ts index 82faab0c9..b657b3101 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw721Staked/actions/UpdateStakingConfig/index.ts +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw721Staked/actions/UpdateStakingConfig/index.ts @@ -1,125 +1,130 @@ -import { useCallback } from 'react' - -import { GearEmoji, useDaoInfoContext } from '@dao-dao/stateless' -import { DurationUnits, Feature } from '@dao-dao/types' +import { daoVotingCw721StakedQueries } from '@dao-dao/state/query' +import { ActionBase, GearEmoji } from '@dao-dao/stateless' +import { + ContractVersion, + DurationUnits, + Feature, + UnifiedCosmosMsg, +} from '@dao-dao/types' import { + ActionContextType, ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, + ActionMatch, + ActionOptions, + ProcessedMessage, } from '@dao-dao/types/actions' import { convertDurationToDurationWithUnits, convertDurationWithUnitsToDuration, isFeatureSupportedByVersion, - makeWasmMessage, + makeExecuteSmartContractMessage, objectMatchesStructure, } from '@dao-dao/utils' -import { useStakingInfo } from '../../hooks' import { UpdateStakingConfigComponent as Component, UpdateStakingConfigData, } from './Component' -const useDefaults: UseDefaults = () => { - const { unstakingDuration } = useStakingInfo() +export class UpdateStakingConfigAction extends ActionBase { + public readonly key = ActionKey.UpdateStakingConfig + public readonly Component = Component - return { - unstakingDurationEnabled: !!unstakingDuration, - unstakingDuration: unstakingDuration - ? convertDurationToDurationWithUnits(unstakingDuration) - : { - value: 2, - units: DurationUnits.Weeks, - }, + private stakingContractAddress: string + private stakingContractVersion: ContractVersion + + constructor(options: ActionOptions) { + // Type-check. + if (options.context.type !== ActionContextType.Dao) { + throw new Error('Invalid context for update staking config action') + } + + super(options, { + Icon: GearEmoji, + label: options.t('title.updateStakingConfig'), + description: options.t('info.updateStakingConfigDescription'), + }) + + this.stakingContractAddress = options.context.dao.votingModule.address + this.stakingContractVersion = options.context.dao.votingModule.version } -} -const useTransformToCosmos: UseTransformToCosmos< - UpdateStakingConfigData -> = () => { - const { coreAddress } = useDaoInfoContext() - const { stakingContractVersion, stakingContractAddress } = useStakingInfo() + async setup() { + const { unstaking_duration } = await this.options.queryClient.fetchQuery( + daoVotingCw721StakedQueries.config(this.options.queryClient, { + chainId: this.options.chain.chain_id, + contractAddress: this.stakingContractAddress, + }) + ) + + this.defaults = { + unstakingDurationEnabled: !!unstaking_duration, + unstakingDuration: unstaking_duration + ? convertDurationToDurationWithUnits(unstaking_duration) + : { + value: 2, + units: DurationUnits.Weeks, + }, + } + } - return useCallback( - ({ unstakingDurationEnabled, unstakingDuration }) => - makeWasmMessage({ + encode({ + unstakingDurationEnabled, + unstakingDuration, + }: UpdateStakingConfigData): UnifiedCosmosMsg { + return makeExecuteSmartContractMessage({ + chainId: this.options.chain.chain_id, + sender: this.options.address, + contractAddress: this.stakingContractAddress, + msg: { + update_config: { + // Prevent unsetting the NFT contract owner when updating config if + // using an old contract version. + ...(!isFeatureSupportedByVersion( + Feature.DaoVotingCw721StakedNoOwner, + this.stakingContractVersion + ) + ? { + owner: this.options.address, + } + : {}), + duration: unstakingDurationEnabled + ? convertDurationWithUnitsToDuration(unstakingDuration) + : null, + }, + }, + }) + } + + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return ( + objectMatchesStructure(decodedMessage, { wasm: { execute: { - contract_addr: stakingContractAddress, - funds: [], + contract_addr: {}, + funds: {}, msg: { - update_config: { - // Prevent unsetting the NFT contract owner when updating config - // if using an old contract version. - ...(!isFeatureSupportedByVersion( - Feature.DaoVotingCw721StakedNoOwner, - stakingContractVersion - ) - ? { - owner: coreAddress, - } - : {}), - duration: unstakingDurationEnabled - ? convertDurationWithUnitsToDuration(unstakingDuration) - : null, - }, + update_config: {}, }, }, }, - }), - [coreAddress, stakingContractAddress, stakingContractVersion] - ) -} - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - const { stakingContractAddress } = useStakingInfo() + }) && + decodedMessage.wasm.execute.contract_addr === this.stakingContractAddress + ) + } - return objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - update_config: {}, - }, - }, - }, - }) && msg.wasm.execute.contract_addr === stakingContractAddress - ? { - match: true, - data: { - unstakingDurationEnabled: - !!msg.wasm.execute.msg.update_config.duration, - unstakingDuration: msg.wasm.execute.msg.update_config.duration - ? convertDurationToDurationWithUnits( - msg.wasm.execute.msg.update_config.duration - ) - : { - value: 2, - units: DurationUnits.Weeks, - }, - }, - } - : { - match: false, - } + decode([{ decodedMessage }]: ProcessedMessage[]): UpdateStakingConfigData { + return { + unstakingDurationEnabled: + !!decodedMessage.wasm.execute.msg.update_config.duration, + unstakingDuration: decodedMessage.wasm.execute.msg.update_config.duration + ? convertDurationToDurationWithUnits( + decodedMessage.wasm.execute.msg.update_config.duration + ) + : { + value: 2, + units: DurationUnits.Weeks, + }, + } + } } - -export const makeUpdateStakingConfigAction: ActionMaker< - UpdateStakingConfigData -> = ({ t }) => ({ - key: ActionKey.UpdateStakingConfig, - Icon: GearEmoji, - label: t('title.updateStakingConfig'), - description: t('info.updateStakingConfigDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - notReusable: true, -}) diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw721Staked/components/MembersTab.tsx b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw721Staked/components/MembersTab.tsx index 623ccd908..bb4c4feb3 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw721Staked/components/MembersTab.tsx +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw721Staked/components/MembersTab.tsx @@ -10,14 +10,16 @@ import { DaoMemberCard, EntityDisplay, } from '../../../../components' -import { useQueryLoadingDataWithError } from '../../../../hooks' +import { + useDaoGovernanceToken, + useQueryLoadingDataWithError, +} from '../../../../hooks' import { useVotingModuleAdapterOptions } from '../../../react/context' -import { useCommonGovernanceTokenInfo } from '../hooks' export const MembersTab = () => { const { t } = useTranslation() const { chainId, votingModuleAddress } = useVotingModuleAdapterOptions() - const token = useCommonGovernanceTokenInfo() + const token = useDaoGovernanceToken() ?? undefined const queryClient = useQueryClient() const members = useQueryLoadingDataWithError( diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw721Staked/hooks/index.ts b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw721Staked/hooks/index.ts index 4e2bb40ef..a28433a26 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw721Staked/hooks/index.ts +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw721Staked/hooks/index.ts @@ -1,4 +1,3 @@ -export * from './useCommonGovernanceTokenInfo' export * from './useGovernanceCollectionInfo' export * from './useMainDaoInfoCards' export * from './useStakingInfo' diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw721Staked/hooks/useCommonGovernanceTokenInfo.ts b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw721Staked/hooks/useCommonGovernanceTokenInfo.ts deleted file mode 100644 index 6143ef082..000000000 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw721Staked/hooks/useCommonGovernanceTokenInfo.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { useRecoilValue } from 'recoil' - -import { - CommonNftSelectors, - DaoVotingCw721StakedSelectors, -} from '@dao-dao/state/recoil' -import { GenericToken, TokenType } from '@dao-dao/types' - -import { useVotingModuleAdapterOptions } from '../../../react/context' - -export const useCommonGovernanceTokenInfo = (): GenericToken => { - const { chainId, votingModuleAddress } = useVotingModuleAdapterOptions() - - const { nft_address: collectionAddress } = useRecoilValue( - DaoVotingCw721StakedSelectors.configSelector({ - chainId, - contractAddress: votingModuleAddress, - params: [], - }) - ) - - const contractInfo = useRecoilValue( - CommonNftSelectors.contractInfoSelector({ - chainId, - contractAddress: collectionAddress, - params: [], - }) - ) - - return { - chainId, - type: TokenType.Cw721, - denomOrAddress: collectionAddress, - symbol: contractInfo.symbol, - decimals: 0, - imageUrl: undefined, - source: { - chainId, - type: TokenType.Cw721, - denomOrAddress: collectionAddress, - }, - } -} diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw721Staked/index.ts b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw721Staked/index.ts index 3a314469c..0a0b38d33 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw721Staked/index.ts +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw721Staked/index.ts @@ -7,6 +7,7 @@ import { import { MainDaoInfoCardsTokenLoader } from '@dao-dao/stateless' import { ActionCategoryKey, + ActionKey, DaoTabId, VotingModuleAdapter, } from '@dao-dao/types' @@ -15,17 +16,13 @@ import { isSecretNetwork, } from '@dao-dao/utils' -import { makeUpdateStakingConfigAction } from './actions' +import { UpdateStakingConfigAction } from './actions' import { MembersTab, NftCollectionTab, ProfileCardMemberInfo, } from './components' -import { - useCommonGovernanceTokenInfo, - useMainDaoInfoCards, - useVotingModuleRelevantAddresses, -} from './hooks' +import { useMainDaoInfoCards, useVotingModuleRelevantAddresses } from './hooks' export const DaoVotingCw721StakedAdapter: VotingModuleAdapter = { id: 'DaoVotingCw721Staked', @@ -36,7 +33,6 @@ export const DaoVotingCw721StakedAdapter: VotingModuleAdapter = { hooks: { useMainDaoInfoCards, useVotingModuleRelevantAddresses, - useCommonGovernanceTokenInfo, }, // Components @@ -69,13 +65,16 @@ export const DaoVotingCw721StakedAdapter: VotingModuleAdapter = { // Functions fields: { - actionCategoryMakers: [ - () => ({ + actions: { + actions: [UpdateStakingConfigAction], + categoryMakers: [ // Add to DAO Governance category. - key: ActionCategoryKey.DaoGovernance, - actionMakers: [makeUpdateStakingConfigAction], - }), - ], + () => ({ + key: ActionCategoryKey.DaoGovernance, + actionKeys: [ActionKey.UpdateStakingConfig], + }), + ], + }, }, }), } diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/actions/Mint/MintComponent.stories.tsx b/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/actions/Mint/MintComponent.stories.tsx index 86ba17ad3..141d86e1a 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/actions/Mint/MintComponent.stories.tsx +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/actions/Mint/MintComponent.stories.tsx @@ -3,8 +3,7 @@ import { ComponentMeta, ComponentStory } from '@storybook/react' import { CHAIN_ID, makeReactHookFormDecorator } from '@dao-dao/storybook' import { TokenType } from '@dao-dao/types' -import { MintData } from '.' -import { MintComponent } from './MintComponent' +import { MintComponent, MintData } from './MintComponent' export default { title: diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/actions/Mint/MintComponent.tsx b/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/actions/Mint/MintComponent.tsx index a74670e34..5051a6837 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/actions/Mint/MintComponent.tsx +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/actions/Mint/MintComponent.tsx @@ -9,7 +9,11 @@ import { validateRequired, } from '@dao-dao/utils' -export interface MintOptions { +export type MintData = { + amount: number +} + +export type MintOptions = { govToken: GenericToken } diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/actions/Mint/index.tsx b/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/actions/Mint/index.tsx index 1d1ce5bfe..97a7ee39a 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/actions/Mint/index.tsx +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/actions/Mint/index.tsx @@ -1,15 +1,18 @@ -import { Coin, coin } from '@cosmjs/stargate' -import { useCallback, useMemo } from 'react' +import { coin } from '@cosmjs/stargate' -import { HerbEmoji } from '@dao-dao/stateless' -import { makeStargateMessage } from '@dao-dao/types' +import { ActionBase, HerbEmoji } from '@dao-dao/stateless' +import { + GenericToken, + UnifiedCosmosMsg, + makeStargateMessage, +} from '@dao-dao/types' import { ActionComponent, + ActionContextType, ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, + ActionMatch, + ActionOptions, + ProcessedMessage, } from '@dao-dao/types/actions' import { MsgMint } from '@dao-dao/types/protobuf/codegen/osmosis/tokenfactory/v1beta1/tx' import { @@ -18,97 +21,98 @@ import { isDecodedStargateMsg, } from '@dao-dao/utils' -import { useActionOptions } from '../../../../../actions' import { useGovernanceTokenInfo } from '../../hooks' -import { MintComponent as StatelessMintComponent } from './MintComponent' - -export interface MintData { - amount: number -} - -const useDefaults: UseDefaults = () => ({ - amount: 1, -}) +import { + MintData, + MintComponent as StatelessMintComponent, +} from './MintComponent' -const useTransformToCosmos: UseTransformToCosmos = () => { - const { address } = useActionOptions() +const Component: ActionComponent = (props) => { const { governanceToken } = useGovernanceTokenInfo() - return useCallback( - (data: MintData) => { - return makeStargateMessage({ - stargate: { - typeUrl: MsgMint.typeUrl, - value: { - sender: address, - amount: coin( - convertDenomToMicroDenomStringWithDecimals( - data.amount, - governanceToken.decimals - ), - governanceToken.denomOrAddress - ), - } as MsgMint, - }, - }) - }, - [address, governanceToken] + return ( + ) } -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - const { governanceToken } = useGovernanceTokenInfo() +export class MintAction extends ActionBase { + public readonly key = ActionKey.Mint + public readonly Component = Component + + protected _defaults: MintData = { + amount: 1, + } + + private governanceToken?: GenericToken + + constructor(options: ActionOptions) { + super(options, { + Icon: HerbEmoji, + label: options.t('title.mint'), + description: options.t('info.mintActionDescription'), + }) + } - return useMemo(() => { + async setup() { + // Type-check. if ( - !isDecodedStargateMsg(msg) || - msg.stargate.typeUrl !== MsgMint.typeUrl + this.options.context.type !== ActionContextType.Dao || + !this.options.context.dao.votingModule.getGovernanceTokenQuery ) { - return { - match: false, - } + throw new Error('Invalid context for mint action') } - const { denom, amount } = msg.stargate.value.amount as Coin + this.governanceToken = await this.options.queryClient.fetchQuery( + this.options.context.dao.votingModule.getGovernanceTokenQuery() + ) + } - return governanceToken.denomOrAddress === denom - ? { - match: true, - data: { - amount: convertMicroDenomToDenomWithDecimals( + encode({ amount }: MintData): UnifiedCosmosMsg { + if (!this.governanceToken) { + throw new Error('Action not ready') + } + + return makeStargateMessage({ + stargate: { + typeUrl: MsgMint.typeUrl, + value: MsgMint.fromPartial({ + sender: this.options.address, + amount: coin( + convertDenomToMicroDenomStringWithDecimals( amount, - governanceToken.decimals + this.governanceToken.decimals ), - }, - } - : { - match: false, - } - }, [governanceToken, msg]) -} + this.governanceToken.denomOrAddress + ), + }), + }, + }) + } -const Component: ActionComponent = (props) => { - const { governanceToken } = useGovernanceTokenInfo() + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return ( + isDecodedStargateMsg(decodedMessage, MsgMint) && + !!this.governanceToken && + decodedMessage.stargate.value.amount.denom === + this.governanceToken.denomOrAddress + ) + } - return ( - - ) -} + decode([{ decodedMessage }]: ProcessedMessage[]): MintData { + if (!this.governanceToken) { + throw new Error('Action not ready') + } -export const makeMintAction: ActionMaker = ({ t }) => ({ - key: ActionKey.Mint, - Icon: HerbEmoji, - label: t('title.mint'), - description: t('info.mintActionDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, -}) + return { + amount: convertMicroDenomToDenomWithDecimals( + decodedMessage.stargate.value.amount.amount, + this.governanceToken.decimals + ), + } + } +} diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/actions/UpdateStakingConfig/index.ts b/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/actions/UpdateStakingConfig/index.ts index bddfdf908..8185b4b0e 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/actions/UpdateStakingConfig/index.ts +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/actions/UpdateStakingConfig/index.ts @@ -1,113 +1,112 @@ -import { useCallback } from 'react' - -import { GearEmoji } from '@dao-dao/stateless' -import { DurationUnits } from '@dao-dao/types' +import { daoVotingNativeStakedQueries } from '@dao-dao/state/query' +import { ActionBase, GearEmoji } from '@dao-dao/stateless' +import { DurationUnits, UnifiedCosmosMsg } from '@dao-dao/types' import { + ActionContextType, ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, + ActionMatch, + ActionOptions, + ProcessedMessage, } from '@dao-dao/types/actions' import { convertDurationToDurationWithUnits, convertDurationWithUnitsToDuration, - makeWasmMessage, + makeExecuteSmartContractMessage, objectMatchesStructure, } from '@dao-dao/utils' -import { useStakingInfo } from '../../hooks' import { UpdateStakingConfigComponent as Component, UpdateStakingConfigData, } from './Component' -const useDefaults: UseDefaults = () => { - const { unstakingDuration } = useStakingInfo() +export class UpdateStakingConfigAction extends ActionBase { + public readonly key = ActionKey.UpdateStakingConfig + public readonly Component = Component - return { - unstakingDurationEnabled: !!unstakingDuration, - unstakingDuration: unstakingDuration - ? convertDurationToDurationWithUnits(unstakingDuration) - : { - value: 2, - units: DurationUnits.Weeks, - }, + private stakingContractAddress: string + + constructor(options: ActionOptions) { + // Type-check. + if (options.context.type !== ActionContextType.Dao) { + throw new Error('Invalid context for update staking config action') + } + + super(options, { + Icon: GearEmoji, + label: options.t('title.updateStakingConfig'), + description: options.t('info.updateStakingConfigDescription'), + }) + + this.stakingContractAddress = options.context.dao.votingModule.address } -} -const useTransformToCosmos: UseTransformToCosmos< - UpdateStakingConfigData -> = () => { - const { stakingContractAddress } = useStakingInfo() + async setup() { + const { unstaking_duration } = await this.options.queryClient.fetchQuery( + daoVotingNativeStakedQueries.getConfig(this.options.queryClient, { + chainId: this.options.chain.chain_id, + contractAddress: this.stakingContractAddress, + }) + ) + + this.defaults = { + unstakingDurationEnabled: !!unstaking_duration, + unstakingDuration: unstaking_duration + ? convertDurationToDurationWithUnits(unstaking_duration) + : { + value: 2, + units: DurationUnits.Weeks, + }, + } + } + + encode({ + unstakingDurationEnabled, + unstakingDuration, + }: UpdateStakingConfigData): UnifiedCosmosMsg { + return makeExecuteSmartContractMessage({ + chainId: this.options.chain.chain_id, + sender: this.options.address, + contractAddress: this.stakingContractAddress, + msg: { + update_config: { + duration: unstakingDurationEnabled + ? convertDurationWithUnitsToDuration(unstakingDuration) + : null, + }, + }, + }) + } - return useCallback( - ({ unstakingDurationEnabled, unstakingDuration }) => - makeWasmMessage({ + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return ( + objectMatchesStructure(decodedMessage, { wasm: { execute: { - contract_addr: stakingContractAddress, - funds: [], + contract_addr: {}, + funds: {}, msg: { - update_config: { - duration: unstakingDurationEnabled - ? convertDurationWithUnitsToDuration(unstakingDuration) - : null, - }, + update_config: {}, }, }, }, - }), - [stakingContractAddress] - ) -} - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - const { stakingContractAddress } = useStakingInfo() + }) && + decodedMessage.wasm.execute.contract_addr === this.stakingContractAddress + ) + } - return objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - update_config: {}, - }, - }, - }, - }) && msg.wasm.execute.contract_addr === stakingContractAddress - ? { - match: true, - data: { - unstakingDurationEnabled: - !!msg.wasm.execute.msg.update_config.duration, - unstakingDuration: msg.wasm.execute.msg.update_config.duration - ? convertDurationToDurationWithUnits( - msg.wasm.execute.msg.update_config.duration - ) - : { - value: 2, - units: DurationUnits.Weeks, - }, - }, - } - : { - match: false, - } + decode([{ decodedMessage }]: ProcessedMessage[]): UpdateStakingConfigData { + return { + unstakingDurationEnabled: + !!decodedMessage.wasm.execute.msg.update_config.duration, + unstakingDuration: decodedMessage.wasm.execute.msg.update_config.duration + ? convertDurationToDurationWithUnits( + decodedMessage.wasm.execute.msg.update_config.duration + ) + : { + value: 2, + units: DurationUnits.Weeks, + }, + } + } } - -export const makeUpdateStakingConfigAction: ActionMaker< - UpdateStakingConfigData -> = ({ t }) => ({ - key: ActionKey.UpdateStakingConfig, - Icon: GearEmoji, - label: t('title.updateStakingConfig'), - description: t('info.updateStakingConfigDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - notReusable: true, -}) diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/hooks/index.ts b/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/hooks/index.ts index 1345985a8..ca93746a8 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/hooks/index.ts +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/hooks/index.ts @@ -1,4 +1,3 @@ -export * from './useCommonGovernanceTokenInfo' export * from './useGovernanceTokenInfo' export * from './useMainDaoInfoCards' export * from './useStakingInfo' diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/hooks/useCommonGovernanceTokenInfo.ts b/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/hooks/useCommonGovernanceTokenInfo.ts deleted file mode 100644 index 100c2b9fe..000000000 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/hooks/useCommonGovernanceTokenInfo.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useRecoilValue } from 'recoil' - -import { - DaoVotingNativeStakedSelectors, - genericTokenSelector, -} from '@dao-dao/state/recoil' -import { GenericToken, TokenType } from '@dao-dao/types' - -import { useVotingModuleAdapterOptions } from '../../../react/context' - -export const useCommonGovernanceTokenInfo = (): GenericToken => { - const { chainId, votingModuleAddress } = useVotingModuleAdapterOptions() - - const { denom } = useRecoilValue( - DaoVotingNativeStakedSelectors.getConfigSelector({ - chainId, - contractAddress: votingModuleAddress, - params: [], - }) - ) - - const eitherTokenInfo = useRecoilValue( - genericTokenSelector({ - chainId, - type: TokenType.Native, - denomOrAddress: denom, - }) - ) - - return eitherTokenInfo -} diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/index.ts b/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/index.ts index 19908f982..5ee03e09f 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/index.ts +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingNativeStaked/index.ts @@ -3,6 +3,7 @@ import { PeopleAltOutlined, PeopleAltRounded } from '@mui/icons-material' import { MainDaoInfoCardsTokenLoader } from '@dao-dao/stateless' import { ActionCategoryKey, + ActionKey, DaoTabId, VotingModuleAdapter, } from '@dao-dao/types' @@ -11,9 +12,9 @@ import { DaoVotingNativeStakedAdapterId, } from '@dao-dao/utils' -import { makeMintAction, makeUpdateStakingConfigAction } from './actions' +import { MintAction, UpdateStakingConfigAction } from './actions' import { MembersTab, ProfileCardMemberInfo, StakingModal } from './components' -import { useCommonGovernanceTokenInfo, useMainDaoInfoCards } from './hooks' +import { useMainDaoInfoCards } from './hooks' export const DaoVotingNativeStakedAdapter: VotingModuleAdapter = { id: DaoVotingNativeStakedAdapterId, @@ -24,7 +25,6 @@ export const DaoVotingNativeStakedAdapter: VotingModuleAdapter = { hooks: { useMainDaoInfoCards, useVotingModuleRelevantAddresses: () => [], - useCommonGovernanceTokenInfo, }, // Components @@ -46,13 +46,16 @@ export const DaoVotingNativeStakedAdapter: VotingModuleAdapter = { // Functions fields: { - actionCategoryMakers: [ - () => ({ + actions: { + actions: [MintAction, UpdateStakingConfigAction], + categoryMakers: [ // Add to DAO Governance category. - key: ActionCategoryKey.DaoGovernance, - actionMakers: [makeMintAction, makeUpdateStakingConfigAction], - }), - ], + () => ({ + key: ActionCategoryKey.DaoGovernance, + actionKeys: [ActionKey.Mint, ActionKey.UpdateStakingConfig], + }), + ], + }, }, }), } diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingOnftStaked/actions/UpdateStakingConfig/index.ts b/packages/stateful/voting-module-adapter/adapters/DaoVotingOnftStaked/actions/UpdateStakingConfig/index.ts index bddfdf908..ef441e94f 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingOnftStaked/actions/UpdateStakingConfig/index.ts +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingOnftStaked/actions/UpdateStakingConfig/index.ts @@ -1,113 +1,112 @@ -import { useCallback } from 'react' - -import { GearEmoji } from '@dao-dao/stateless' -import { DurationUnits } from '@dao-dao/types' +import { daoVotingOnftStakedQueries } from '@dao-dao/state/query' +import { ActionBase, GearEmoji } from '@dao-dao/stateless' +import { DurationUnits, UnifiedCosmosMsg } from '@dao-dao/types' import { + ActionContextType, ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, + ActionMatch, + ActionOptions, + ProcessedMessage, } from '@dao-dao/types/actions' import { convertDurationToDurationWithUnits, convertDurationWithUnitsToDuration, - makeWasmMessage, + makeExecuteSmartContractMessage, objectMatchesStructure, } from '@dao-dao/utils' -import { useStakingInfo } from '../../hooks' import { UpdateStakingConfigComponent as Component, UpdateStakingConfigData, } from './Component' -const useDefaults: UseDefaults = () => { - const { unstakingDuration } = useStakingInfo() +export class UpdateStakingConfigAction extends ActionBase { + public readonly key = ActionKey.UpdateStakingConfig + public readonly Component = Component - return { - unstakingDurationEnabled: !!unstakingDuration, - unstakingDuration: unstakingDuration - ? convertDurationToDurationWithUnits(unstakingDuration) - : { - value: 2, - units: DurationUnits.Weeks, - }, + private stakingContractAddress: string + + constructor(options: ActionOptions) { + // Type-check. + if (options.context.type !== ActionContextType.Dao) { + throw new Error('Invalid context for update staking config action') + } + + super(options, { + Icon: GearEmoji, + label: options.t('title.updateStakingConfig'), + description: options.t('info.updateStakingConfigDescription'), + }) + + this.stakingContractAddress = options.context.dao.votingModule.address } -} -const useTransformToCosmos: UseTransformToCosmos< - UpdateStakingConfigData -> = () => { - const { stakingContractAddress } = useStakingInfo() + async setup() { + const { unstaking_duration } = await this.options.queryClient.fetchQuery( + daoVotingOnftStakedQueries.config(this.options.queryClient, { + chainId: this.options.chain.chain_id, + contractAddress: this.stakingContractAddress, + }) + ) + + this.defaults = { + unstakingDurationEnabled: !!unstaking_duration, + unstakingDuration: unstaking_duration + ? convertDurationToDurationWithUnits(unstaking_duration) + : { + value: 2, + units: DurationUnits.Weeks, + }, + } + } + + encode({ + unstakingDurationEnabled, + unstakingDuration, + }: UpdateStakingConfigData): UnifiedCosmosMsg { + return makeExecuteSmartContractMessage({ + chainId: this.options.chain.chain_id, + sender: this.options.address, + contractAddress: this.stakingContractAddress, + msg: { + update_config: { + duration: unstakingDurationEnabled + ? convertDurationWithUnitsToDuration(unstakingDuration) + : null, + }, + }, + }) + } - return useCallback( - ({ unstakingDurationEnabled, unstakingDuration }) => - makeWasmMessage({ + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return ( + objectMatchesStructure(decodedMessage, { wasm: { execute: { - contract_addr: stakingContractAddress, - funds: [], + contract_addr: {}, + funds: {}, msg: { - update_config: { - duration: unstakingDurationEnabled - ? convertDurationWithUnitsToDuration(unstakingDuration) - : null, - }, + update_config: {}, }, }, }, - }), - [stakingContractAddress] - ) -} - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - const { stakingContractAddress } = useStakingInfo() + }) && + decodedMessage.wasm.execute.contract_addr === this.stakingContractAddress + ) + } - return objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - update_config: {}, - }, - }, - }, - }) && msg.wasm.execute.contract_addr === stakingContractAddress - ? { - match: true, - data: { - unstakingDurationEnabled: - !!msg.wasm.execute.msg.update_config.duration, - unstakingDuration: msg.wasm.execute.msg.update_config.duration - ? convertDurationToDurationWithUnits( - msg.wasm.execute.msg.update_config.duration - ) - : { - value: 2, - units: DurationUnits.Weeks, - }, - }, - } - : { - match: false, - } + decode([{ decodedMessage }]: ProcessedMessage[]): UpdateStakingConfigData { + return { + unstakingDurationEnabled: + !!decodedMessage.wasm.execute.msg.update_config.duration, + unstakingDuration: decodedMessage.wasm.execute.msg.update_config.duration + ? convertDurationToDurationWithUnits( + decodedMessage.wasm.execute.msg.update_config.duration + ) + : { + value: 2, + units: DurationUnits.Weeks, + }, + } + } } - -export const makeUpdateStakingConfigAction: ActionMaker< - UpdateStakingConfigData -> = ({ t }) => ({ - key: ActionKey.UpdateStakingConfig, - Icon: GearEmoji, - label: t('title.updateStakingConfig'), - description: t('info.updateStakingConfigDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - notReusable: true, -}) diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingOnftStaked/components/MembersTab.tsx b/packages/stateful/voting-module-adapter/adapters/DaoVotingOnftStaked/components/MembersTab.tsx index a419c9475..e7f33143d 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingOnftStaked/components/MembersTab.tsx +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingOnftStaked/components/MembersTab.tsx @@ -10,14 +10,16 @@ import { DaoMemberCard, EntityDisplay, } from '../../../../components' -import { useQueryLoadingDataWithError } from '../../../../hooks' +import { + useDaoGovernanceToken, + useQueryLoadingDataWithError, +} from '../../../../hooks' import { useVotingModuleAdapterOptions } from '../../../react/context' -import { useCommonGovernanceTokenInfo } from '../hooks' export const MembersTab = () => { const { t } = useTranslation() const { chainId, votingModuleAddress } = useVotingModuleAdapterOptions() - const token = useCommonGovernanceTokenInfo() + const token = useDaoGovernanceToken() ?? undefined const queryClient = useQueryClient() const members = useQueryLoadingDataWithError( diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingOnftStaked/hooks/index.ts b/packages/stateful/voting-module-adapter/adapters/DaoVotingOnftStaked/hooks/index.ts index 4e2bb40ef..a28433a26 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingOnftStaked/hooks/index.ts +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingOnftStaked/hooks/index.ts @@ -1,4 +1,3 @@ -export * from './useCommonGovernanceTokenInfo' export * from './useGovernanceCollectionInfo' export * from './useMainDaoInfoCards' export * from './useStakingInfo' diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingOnftStaked/hooks/useCommonGovernanceTokenInfo.ts b/packages/stateful/voting-module-adapter/adapters/DaoVotingOnftStaked/hooks/useCommonGovernanceTokenInfo.ts deleted file mode 100644 index 297a12641..000000000 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingOnftStaked/hooks/useCommonGovernanceTokenInfo.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query' - -import { - daoVotingOnftStakedQueries, - omniflixQueries, -} from '@dao-dao/state/query' -import { GenericToken, TokenType } from '@dao-dao/types' - -import { useVotingModuleAdapterOptions } from '../../../react/context' - -export const useCommonGovernanceTokenInfo = (): GenericToken => { - const { chainId, votingModuleAddress } = useVotingModuleAdapterOptions() - - const queryClient = useQueryClient() - const { - data: { onft_collection_id }, - } = useSuspenseQuery( - daoVotingOnftStakedQueries.config(queryClient, { - chainId, - contractAddress: votingModuleAddress, - }) - ) - - const { - data: { symbol, previewUri }, - } = useSuspenseQuery( - omniflixQueries.onftCollectionInfo({ - chainId, - id: onft_collection_id, - }) - ) - - return { - chainId, - type: TokenType.Onft, - denomOrAddress: onft_collection_id, - symbol, - decimals: 0, - imageUrl: previewUri, - source: { - chainId, - type: TokenType.Onft, - denomOrAddress: onft_collection_id, - }, - } -} diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingOnftStaked/index.ts b/packages/stateful/voting-module-adapter/adapters/DaoVotingOnftStaked/index.ts index 30f9cb88c..6b3a27765 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingOnftStaked/index.ts +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingOnftStaked/index.ts @@ -7,22 +7,19 @@ import { import { MainDaoInfoCardsTokenLoader } from '@dao-dao/stateless' import { ActionCategoryKey, + ActionKey, DaoTabId, VotingModuleAdapter, } from '@dao-dao/types' import { DAO_VOTING_ONFT_STAKED_CONTRACT_NAMES } from '@dao-dao/utils' -import { makeUpdateStakingConfigAction } from './actions' +import { UpdateStakingConfigAction } from './actions' import { MembersTab, NftCollectionTab, ProfileCardMemberInfo, } from './components' -import { - useCommonGovernanceTokenInfo, - useMainDaoInfoCards, - useVotingModuleRelevantAddresses, -} from './hooks' +import { useMainDaoInfoCards, useVotingModuleRelevantAddresses } from './hooks' export const DaoVotingOnftStakedAdapter: VotingModuleAdapter = { id: 'DaoVotingOnftStaked', @@ -33,7 +30,6 @@ export const DaoVotingOnftStakedAdapter: VotingModuleAdapter = { hooks: { useMainDaoInfoCards, useVotingModuleRelevantAddresses, - useCommonGovernanceTokenInfo, }, // Components @@ -61,13 +57,16 @@ export const DaoVotingOnftStakedAdapter: VotingModuleAdapter = { // Functions fields: { - actionCategoryMakers: [ - () => ({ + actions: { + actions: [UpdateStakingConfigAction], + categoryMakers: [ // Add to DAO Governance category. - key: ActionCategoryKey.DaoGovernance, - actionMakers: [makeUpdateStakingConfigAction], - }), - ], + () => ({ + key: ActionCategoryKey.DaoGovernance, + actionKeys: [ActionKey.UpdateStakingConfig], + }), + ], + }, }, }), } diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingSgCommunityNft/index.ts b/packages/stateful/voting-module-adapter/adapters/DaoVotingSgCommunityNft/index.ts index 097c61f57..d144f3cee 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingSgCommunityNft/index.ts +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingSgCommunityNft/index.ts @@ -36,8 +36,6 @@ export const DaoVotingSgCommunityNftAdapter: VotingModuleAdapter = { }, // Functions - fields: { - actionCategoryMakers: [], - }, + fields: {}, }), } diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/MigrateMigalooV4TokenFactory/Component.stories.tsx b/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/MigrateMigalooV4TokenFactory/Component.stories.tsx deleted file mode 100644 index dad35aed0..000000000 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/MigrateMigalooV4TokenFactory/Component.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react' - -import { makeReactHookFormDecorator } from '@dao-dao/storybook' - -import { - MigrateMigalooV4TokenFactoryComponent, - MigrateMigalooV4TokenFactoryData, -} from './Component' - -export default { - title: - 'DAO DAO / packages / stateful / voting-module-adapter / adapters / DaoVotingTokenStaked / actions / MigrateMigalooV4TokenFactory', - component: MigrateMigalooV4TokenFactoryComponent, - decorators: [makeReactHookFormDecorator()], -} as ComponentMeta - -const Template: ComponentStory = ( - args -) => - -export const Default = Template.bind({}) -Default.args = { - fieldNamePrefix: '', - allActionsWithData: [], - index: 0, - data: {}, - isCreating: true, -} diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/MigrateMigalooV4TokenFactory/Component.tsx b/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/MigrateMigalooV4TokenFactory/Component.tsx deleted file mode 100644 index 498018679..000000000 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/MigrateMigalooV4TokenFactory/Component.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { useTranslation } from 'react-i18next' - -import { ActionComponent } from '@dao-dao/types' - -export type MigrateMigalooV4TokenFactoryData = {} - -export const MigrateMigalooV4TokenFactoryComponent: ActionComponent = () => { - const { t } = useTranslation() - - return ( -

- {t('info.migrateMigalooV4TokenFactoryExplanation')} -

- ) -} diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/MigrateMigalooV4TokenFactory/README.md b/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/MigrateMigalooV4TokenFactory/README.md deleted file mode 100644 index 5d1b05472..000000000 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/MigrateMigalooV4TokenFactory/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# MigrateMigalooV4TokenFactory - -Migrate `cw-tokenfactory-issuer` from the CosmWasm x/tokenfactory implementation -to Osmosis's x/tokenfactory. Migaloo is migrating tokenfactory modules, so this -is needed to help DAO's update to the latest contract. - -## 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 - -`migrateMigalooV4TokenFactory` - -### Data format - -```json -{} -``` diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/MigrateMigalooV4TokenFactory/index.tsx b/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/MigrateMigalooV4TokenFactory/index.tsx deleted file mode 100644 index 083743548..000000000 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/MigrateMigalooV4TokenFactory/index.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { useCallback } from 'react' - -import { - DaoVotingTokenStakedSelectors, - contractDetailsSelector, -} from '@dao-dao/state/recoil' -import { PufferfishEmoji, useCachedLoadable } from '@dao-dao/stateless' -import { ChainId } from '@dao-dao/types' -import { - ActionChainContextType, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseHideFromPicker, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { makeWasmMessage, objectMatchesStructure } from '@dao-dao/utils' - -import { useVotingModuleAdapterOptions } from '../../../../react/context' -import { useGovernanceTokenInfo } from '../../hooks' -import { - MigrateMigalooV4TokenFactoryComponent, - MigrateMigalooV4TokenFactoryData, -} from './Component' - -const useDefaults: UseDefaults = () => ({}) - -export const makeMigrateMigalooV4TokenFactoryAction: ActionMaker< - MigrateMigalooV4TokenFactoryData -> = ({ t, chainContext }) => { - // Only Migaloo DAOs need to migrate. - if ( - chainContext.chainId !== ChainId.MigalooMainnet || - chainContext.type !== ActionChainContextType.Supported - ) { - return null - } - - const useTransformToCosmos: UseTransformToCosmos< - MigrateMigalooV4TokenFactoryData - > = () => { - const { tokenFactoryIssuerAddress } = useGovernanceTokenInfo() - - return useCallback( - () => - makeWasmMessage({ - wasm: { - migrate: { - contract_addr: tokenFactoryIssuerAddress, - new_code_id: chainContext.config.codeIds.CwTokenfactoryIssuerMain, - msg: {}, - }, - }, - }), - [tokenFactoryIssuerAddress] - ) - } - - const useDecodedCosmosMsg: UseDecodedCosmosMsg< - MigrateMigalooV4TokenFactoryData - > = (msg: Record) => { - const { tokenFactoryIssuerAddress } = useGovernanceTokenInfo() - - return objectMatchesStructure(msg, { - wasm: { - migrate: { - contract_addr: {}, - new_code_id: {}, - msg: {}, - }, - }, - }) && msg.wasm.migrate.contract_addr === tokenFactoryIssuerAddress - ? { - match: true, - data: {}, - } - : { - match: false, - } - } - - // Only show in picker if using cw-tokenfactory-issuer contract and it's on the - // old version of the contract. - const useHideFromPicker: UseHideFromPicker = () => { - const { chainId, votingModuleAddress } = useVotingModuleAdapterOptions() - - const tfIssuer = useCachedLoadable( - DaoVotingTokenStakedSelectors.validatedTokenfactoryIssuerContractSelector( - { - contractAddress: votingModuleAddress, - chainId, - } - ) - ) - const tfIssuerContract = useCachedLoadable( - tfIssuer.state === 'hasValue' && tfIssuer.contents - ? contractDetailsSelector({ - contractAddress: tfIssuer.contents, - chainId, - }) - : undefined - ) - - return ( - !chainContext.config.codeIds.CwTokenfactoryIssuerCosmWasm || - tfIssuerContract.state !== 'hasValue' || - tfIssuerContract.contents.codeId !== - chainContext.config.codeIds.CwTokenfactoryIssuerCosmWasm - ) - } - - return { - key: ActionKey.MigrateMigalooV4TokenFactory, - Icon: PufferfishEmoji, - label: t('title.migrateTokenFactoryModule'), - description: t('info.migrateTokenFactoryModuleDescription'), - Component: MigrateMigalooV4TokenFactoryComponent, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - useHideFromPicker, - // Show at the top. - order: 1000, - } -} diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/Mint/MintComponent.tsx b/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/Mint/MintComponent.tsx index 5d1e0984a..8480a9025 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/Mint/MintComponent.tsx +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/Mint/MintComponent.tsx @@ -3,10 +3,9 @@ import { SubdirectoryArrowRightRounded, } from '@mui/icons-material' import clsx from 'clsx' -import { ComponentType, useRef } from 'react' +import { ComponentType } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' -import useDeepCompareEffect from 'use-deep-compare-effect' import { InputErrorMessage, @@ -17,7 +16,6 @@ import { } from '@dao-dao/stateless' import { ActionComponent, - ActionKey, AddressInputProps, GenericToken, } from '@dao-dao/types' @@ -28,9 +26,6 @@ import { validateRequired, } from '@dao-dao/utils' -import { useActionOptions } from '../../../../../actions' -import { UpdateMinterAllowanceData } from '../UpdateMinterAllowance/UpdateMinterAllowanceComponent' - export type MintData = { recipient: string amount: number @@ -46,93 +41,14 @@ export const MintComponent: ActionComponent = ({ errors, isCreating, options: { govToken, AddressInput }, - allActionsWithData, - index, - addAction, }) => { const { t } = useTranslation() - const { address } = useActionOptions() - const { register, watch, setValue, getValues } = useFormContext() + const { register, watch, setValue } = useFormContext() const { bech32_prefix: bech32Prefix } = useChain() - const amount = watch((fieldNamePrefix + 'amount') as 'amount') - const { containerRef, childRef, wrapped } = useDetectWrap() const Icon = wrapped ? SubdirectoryArrowRightRounded : ArrowRightAltRounded - // Ensure an UpdateMinterAllowance action exists before this one for the - // needed amount, or create/update otherwise. The needed amount is the sum of - // all mint actions. - const totalAmountNeeded = allActionsWithData - .filter(({ actionKey }) => actionKey === ActionKey.Mint) - .reduce( - (acc, { data }) => acc + ((data as MintData | undefined)?.amount || 0), - 0 - ) - const firstMintActionIndex = allActionsWithData.findIndex( - ({ actionKey }) => actionKey === ActionKey.Mint - ) - const updateMinterAllowanceActionIndex = allActionsWithData.findIndex( - ({ actionKey, data }) => - actionKey === ActionKey.UpdateMinterAllowance && - (data as UpdateMinterAllowanceData | undefined)?.minter === address - ) - // Prevents double-add on initial render. - const created = useRef(false) - useDeepCompareEffect(() => { - if ( - !isCreating || - !addAction || - // If this is not the first mint action, don't do anything. - firstMintActionIndex !== index - ) { - return - } - - // If no action exists, create one right before. - if (updateMinterAllowanceActionIndex === -1) { - // Prevents double-add on initial render. - if (created.current) { - return - } - created.current = true - - addAction( - { - actionKey: ActionKey.UpdateMinterAllowance, - data: { - minter: address, - allowance: amount, - } as UpdateMinterAllowanceData, - }, - index - ) - } else { - // Path to the allowance field on the update minter allowance action. - const existingAllowanceFieldName = fieldNamePrefix.replace( - new RegExp(`${index}\\.data.$`), - `${updateMinterAllowanceActionIndex}.data.allowance` - ) - - // Otherwise if the amount isn't correct, update the existing one. - if (getValues(existingAllowanceFieldName as any) !== totalAmountNeeded) { - setValue(existingAllowanceFieldName as any, totalAmountNeeded) - } - } - }, [ - addAction, - address, - amount, - fieldNamePrefix, - firstMintActionIndex, - getValues, - index, - isCreating, - setValue, - totalAmountNeeded, - updateMinterAllowanceActionIndex, - ]) - return ( <> = () => { - const { - tokenFactoryIssuerAddress, - governanceToken: { decimals }, - } = useGovernanceTokenInfo() - - return useCallback( - ({ recipient, amount }: MintData) => - makeWasmMessage({ - wasm: { - execute: { - contract_addr: tokenFactoryIssuerAddress, - funds: [], - msg: { - mint: { - to_address: recipient, - amount: convertDenomToMicroDenomStringWithDecimals( - amount, - decimals - ), - }, - }, - }, - }, - }), - [decimals, tokenFactoryIssuerAddress] - ) -} - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - const { - tokenFactoryIssuerAddress, - governanceToken: { decimals }, - } = useGovernanceTokenInfo() - - return objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - msg: { - mint: { - amount: {}, - to_address: {}, - }, - }, - }, - }, - }) && msg.wasm.execute.contract_addr === tokenFactoryIssuerAddress - ? { - match: true, - data: { - recipient: msg.wasm.execute.msg.mint.to_address, - amount: convertMicroDenomToDenomWithDecimals( - msg.wasm.execute.msg.mint.amount, - decimals - ), - }, - } - : { - match: false, - } -} - const Component: ActionComponent = (props) => { const { governanceToken } = useGovernanceTokenInfo() @@ -111,53 +37,149 @@ const Component: ActionComponent = (props) => { ) } -// Only show in picker if using cw-tokenfactory-issuer contract. -const useHideFromPicker: UseHideFromPicker = () => { - const { chainId, votingModuleAddress } = useVotingModuleAdapterOptions() - const { chainContext } = useActionOptions() +export class MintAction extends ActionBase { + public readonly key = ActionKey.Mint + public readonly Component = Component + + private governanceToken?: GenericToken + private tokenFactoryIssuerAddress: string | null = null - const tfIssuer = useCachedLoadable( - DaoVotingTokenStakedSelectors.validatedTokenfactoryIssuerContractSelector({ - contractAddress: votingModuleAddress, - chainId, + constructor(options: ActionOptions) { + super(options, { + Icon: HerbEmoji, + label: options.t('title.mint'), + description: options.t('info.mintActionDescription'), + // Hide until ready. Update this in setup. + hideFromPicker: true, }) - ) - const tfIssuerContract = useCachedLoadable( - tfIssuer.state === 'hasValue' && tfIssuer.contents - ? contractDetailsSelector({ - contractAddress: tfIssuer.contents, - chainId, - }) - : undefined - ) - return ( - tfIssuer.state !== 'hasValue' || - !tfIssuer.contents || - // Disallow minting on Miagloo if cw-tokenfactory-issuer is on old version. - (chainContext.chainId === ChainId.MigalooMainnet && - chainContext.type === ActionChainContextType.Supported && - (tfIssuerContract.state !== 'hasValue' || - tfIssuerContract.contents.codeId === - chainContext.config.codeIds.CwTokenfactoryIssuerCosmWasm)) - ) -} + this.defaults = { + recipient: options.address, + amount: 1, + } + + // Fire async init immediately since we may hide this action. + this.init().catch(() => {}) + } + + async setup() { + // Type-check. + if ( + this.options.context.type !== ActionContextType.Dao || + !this.options.context.dao.votingModule.getGovernanceTokenQuery + ) { + throw new Error('Invalid context for mint action') + } + + this.governanceToken = await this.options.queryClient.fetchQuery( + this.options.context.dao.votingModule.getGovernanceTokenQuery() + ) + + this.tokenFactoryIssuerAddress = await this.options.queryClient.fetchQuery( + daoVotingTokenStakedExtraQueries.validatedTokenfactoryIssuerContract( + this.options.queryClient, + { + chainId: this.options.chain.chain_id, + address: this.options.context.dao.votingModule.address, + } + ) + ) + + // Need token factory issuer address to mint. + this.metadata.hideFromPicker = !this.tokenFactoryIssuerAddress + } + + encode({ recipient, amount: _amount }: MintData): UnifiedCosmosMsg[] { + if (!this.governanceToken || !this.tokenFactoryIssuerAddress) { + throw new Error('Action not ready') + } + + const amount = convertDenomToMicroDenomStringWithDecimals( + _amount, + this.governanceToken.decimals + ) + + return [ + // Set DAO minter allowance to the amount we're about to mint. + makeExecuteSmartContractMessage({ + chainId: this.options.chain.chain_id, + sender: this.options.address, + contractAddress: this.tokenFactoryIssuerAddress, + msg: { + set_minter_allowance: { + address: this.options.address, + allowance: amount, + }, + }, + }), + // Mint. + makeExecuteSmartContractMessage({ + chainId: this.options.chain.chain_id, + sender: this.options.address, + contractAddress: this.tokenFactoryIssuerAddress, + msg: { + mint: { + to_address: recipient, + amount, + }, + }, + }), + ] + } + + match(messages: ProcessedMessage[]): ActionMatch { + return !!this.tokenFactoryIssuerAddress && + messages.length >= 2 && + // Set minter allowance. + objectMatchesStructure(messages[0].decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + set_minter_allowance: { + address: {}, + allowance: {}, + }, + }, + }, + }, + }) && + messages[0].decodedMessage.wasm.execute.contract_addr === + this.tokenFactoryIssuerAddress && + // Mint. + objectMatchesStructure(messages[1].decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + mint: { + to_address: {}, + amount: {}, + }, + }, + }, + }, + }) && + messages[1].decodedMessage.wasm.execute.contract_addr === + this.tokenFactoryIssuerAddress + ? // Match both messages. + 2 + : false + } + + decode([_, { decodedMessage }]: ProcessedMessage[]): MintData { + if (!this.governanceToken) { + throw new Error('Action not ready') + } -export const makeMintAction: ActionMaker = ({ t, address }) => { - const useDefaults: UseDefaults = () => ({ - recipient: address, - amount: 1, - }) - - return { - key: ActionKey.Mint, - Icon: HerbEmoji, - label: t('title.mint'), - description: t('info.mintActionDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - useHideFromPicker, + return { + recipient: decodedMessage.wasm.execute.msg.mint.to_address, + amount: convertMicroDenomToDenomWithDecimals( + decodedMessage.wasm.execute.msg.mint.amount, + this.governanceToken.decimals + ), + } } } diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/UpdateMinterAllowance/README.md b/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/UpdateMinterAllowance/README.md deleted file mode 100644 index 6977b6825..000000000 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/UpdateMinterAllowance/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# UpdateMinterAllowance - -Set a minter's allowance. - -## 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 - -`updateMinterAllowance` - -### Data format - -```json -{ - "minter": "", - "allowance": "" -} -``` diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/UpdateMinterAllowance/UpdateMinterAllowanceComponent.stories.tsx b/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/UpdateMinterAllowance/UpdateMinterAllowanceComponent.stories.tsx deleted file mode 100644 index 4015e8861..000000000 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/UpdateMinterAllowance/UpdateMinterAllowanceComponent.stories.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react' - -import { AddressInput } from '@dao-dao/stateless' -import { CHAIN_ID, makeReactHookFormDecorator } from '@dao-dao/storybook' -import { TokenType } from '@dao-dao/types' - -import { - UpdateMinterAllowanceComponent, - UpdateMinterAllowanceData, -} from './UpdateMinterAllowanceComponent' - -export default { - title: - 'DAO DAO / packages / stateful / voting-module-adapter / adapters / DaoVotingTokenStaked / actions / UpdateMinterAllowance', - component: UpdateMinterAllowanceComponent, - decorators: [ - makeReactHookFormDecorator({ - minter: 'address', - allowance: 100000, - }), - ], -} as ComponentMeta - -const Template: ComponentStory = ( - args -) => - -export const Default = Template.bind({}) -Default.args = { - fieldNamePrefix: '', - allActionsWithData: [], - index: 0, - data: {}, - isCreating: true, - options: { - govToken: { - source: { - chainId: CHAIN_ID, - type: TokenType.Native, - denomOrAddress: 'factory/wallet/subdenom', - }, - chainId: CHAIN_ID, - type: TokenType.Native, - denomOrAddress: 'factory/wallet/subdenom', - symbol: 'DENOM', - decimals: 6, - imageUrl: '', - }, - AddressInput, - }, -} diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/UpdateMinterAllowance/UpdateMinterAllowanceComponent.tsx b/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/UpdateMinterAllowance/UpdateMinterAllowanceComponent.tsx deleted file mode 100644 index 21b8af3f9..000000000 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/UpdateMinterAllowance/UpdateMinterAllowanceComponent.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { ComponentType } from 'react' -import { useFormContext } from 'react-hook-form' -import { useTranslation } from 'react-i18next' - -import { - InputErrorMessage, - InputLabel, - NumberInput, - useChain, -} from '@dao-dao/stateless' -import { - ActionComponent, - AddressInputProps, - GenericToken, -} from '@dao-dao/types' -import { - convertMicroDenomToDenomWithDecimals, - makeValidateAddress, - validatePositive, - validateRequired, -} from '@dao-dao/utils' - -export type UpdateMinterAllowanceData = { - minter: string - allowance: number -} - -export type UpdateMinterAllowanceOptions = { - govToken: GenericToken - AddressInput: ComponentType> -} - -export const UpdateMinterAllowanceComponent: ActionComponent< - UpdateMinterAllowanceOptions -> = ({ fieldNamePrefix, errors, options: { govToken, AddressInput } }) => { - const { t } = useTranslation() - const { register, watch, setValue } = - useFormContext() - const { bech32_prefix: bech32Prefix } = useChain() - - return ( - <> -

- {t('info.updateMinterAllowanceExplanation')} -

- -
- - - -
- -
- - - -
- - ) -} diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/UpdateMinterAllowance/index.tsx b/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/UpdateMinterAllowance/index.tsx deleted file mode 100644 index 825fb19e1..000000000 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/UpdateMinterAllowance/index.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { useCallback } from 'react' - -import { DaoVotingTokenStakedSelectors } from '@dao-dao/state/recoil' -import { PrinterEmoji, useCachedLoadable } from '@dao-dao/stateless' -import { - ActionComponent, - ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseHideFromPicker, - UseTransformToCosmos, -} from '@dao-dao/types/actions' -import { - convertDenomToMicroDenomStringWithDecimals, - convertMicroDenomToDenomWithDecimals, - makeWasmMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { AddressInput } from '../../../../../components/AddressInput' -import { useVotingModuleAdapterOptions } from '../../../../react/context' -import { useGovernanceTokenInfo } from '../../hooks' -import { - UpdateMinterAllowanceComponent, - UpdateMinterAllowanceData, -} from './UpdateMinterAllowanceComponent' - -const useTransformToCosmos: UseTransformToCosmos< - UpdateMinterAllowanceData -> = () => { - const { - tokenFactoryIssuerAddress, - governanceToken: { decimals }, - } = useGovernanceTokenInfo() - - return useCallback( - ({ minter, allowance }: UpdateMinterAllowanceData) => - makeWasmMessage({ - wasm: { - execute: { - contract_addr: tokenFactoryIssuerAddress, - funds: [], - msg: { - set_minter_allowance: { - address: minter, - allowance: convertDenomToMicroDenomStringWithDecimals( - allowance, - decimals - ), - }, - }, - }, - }, - }), - [decimals, tokenFactoryIssuerAddress] - ) -} - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - const { - tokenFactoryIssuerAddress, - governanceToken: { decimals }, - } = useGovernanceTokenInfo() - - return objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - msg: { - set_minter_allowance: { - address: {}, - allowance: {}, - }, - }, - }, - }, - }) && msg.wasm.execute.contract_addr === tokenFactoryIssuerAddress - ? { - match: true, - data: { - minter: msg.wasm.execute.msg.set_minter_allowance.address, - allowance: convertMicroDenomToDenomWithDecimals( - msg.wasm.execute.msg.set_minter_allowance.allowance, - decimals - ), - }, - } - : { - match: false, - } -} - -const Component: ActionComponent = (props) => { - const { governanceToken } = useGovernanceTokenInfo() - - return ( - - ) -} - -// Only show in picker if using cw-tokenfactory-issuer contract. -const useHideFromPicker: UseHideFromPicker = () => { - const { chainId, votingModuleAddress } = useVotingModuleAdapterOptions() - - const tfIssuer = useCachedLoadable( - DaoVotingTokenStakedSelectors.validatedTokenfactoryIssuerContractSelector({ - contractAddress: votingModuleAddress, - chainId, - }) - ) - - return tfIssuer.state !== 'hasValue' || !tfIssuer.contents -} - -export const makeUpdateMinterAllowanceAction: ActionMaker< - UpdateMinterAllowanceData -> = ({ t, address }) => { - const useDefaults: UseDefaults = () => ({ - minter: address, - allowance: 1, - }) - - return { - key: ActionKey.UpdateMinterAllowance, - Icon: PrinterEmoji, - label: t('title.updateMinterAllowance'), - description: t('info.updateMinterAllowanceDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - useHideFromPicker, - // Programmatically add when minting, but don't reveal by itself. This is - // dangerous and probably won't be used. - programmaticOnly: true, - } -} diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/UpdateStakingConfig/index.ts b/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/UpdateStakingConfig/index.ts index bddfdf908..7d81e9222 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/UpdateStakingConfig/index.ts +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/UpdateStakingConfig/index.ts @@ -1,113 +1,112 @@ -import { useCallback } from 'react' - -import { GearEmoji } from '@dao-dao/stateless' -import { DurationUnits } from '@dao-dao/types' +import { daoVotingTokenStakedQueries } from '@dao-dao/state/query' +import { ActionBase, GearEmoji } from '@dao-dao/stateless' +import { DurationUnits, UnifiedCosmosMsg } from '@dao-dao/types' import { + ActionContextType, ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, + ActionMatch, + ActionOptions, + ProcessedMessage, } from '@dao-dao/types/actions' import { convertDurationToDurationWithUnits, convertDurationWithUnitsToDuration, - makeWasmMessage, + makeExecuteSmartContractMessage, objectMatchesStructure, } from '@dao-dao/utils' -import { useStakingInfo } from '../../hooks' import { UpdateStakingConfigComponent as Component, UpdateStakingConfigData, } from './Component' -const useDefaults: UseDefaults = () => { - const { unstakingDuration } = useStakingInfo() +export class UpdateStakingConfigAction extends ActionBase { + public readonly key = ActionKey.UpdateStakingConfig + public readonly Component = Component - return { - unstakingDurationEnabled: !!unstakingDuration, - unstakingDuration: unstakingDuration - ? convertDurationToDurationWithUnits(unstakingDuration) - : { - value: 2, - units: DurationUnits.Weeks, - }, + private stakingContractAddress: string + + constructor(options: ActionOptions) { + // Type-check. + if (options.context.type !== ActionContextType.Dao) { + throw new Error('Invalid context for update staking config action') + } + + super(options, { + Icon: GearEmoji, + label: options.t('title.updateStakingConfig'), + description: options.t('info.updateStakingConfigDescription'), + }) + + this.stakingContractAddress = options.context.dao.votingModule.address } -} -const useTransformToCosmos: UseTransformToCosmos< - UpdateStakingConfigData -> = () => { - const { stakingContractAddress } = useStakingInfo() + async setup() { + const { unstaking_duration } = await this.options.queryClient.fetchQuery( + daoVotingTokenStakedQueries.getConfig(this.options.queryClient, { + chainId: this.options.chain.chain_id, + contractAddress: this.stakingContractAddress, + }) + ) + + this.defaults = { + unstakingDurationEnabled: !!unstaking_duration, + unstakingDuration: unstaking_duration + ? convertDurationToDurationWithUnits(unstaking_duration) + : { + value: 2, + units: DurationUnits.Weeks, + }, + } + } + + encode({ + unstakingDurationEnabled, + unstakingDuration, + }: UpdateStakingConfigData): UnifiedCosmosMsg { + return makeExecuteSmartContractMessage({ + chainId: this.options.chain.chain_id, + sender: this.options.address, + contractAddress: this.stakingContractAddress, + msg: { + update_config: { + duration: unstakingDurationEnabled + ? convertDurationWithUnitsToDuration(unstakingDuration) + : null, + }, + }, + }) + } - return useCallback( - ({ unstakingDurationEnabled, unstakingDuration }) => - makeWasmMessage({ + match([{ decodedMessage }]: ProcessedMessage[]): ActionMatch { + return ( + objectMatchesStructure(decodedMessage, { wasm: { execute: { - contract_addr: stakingContractAddress, - funds: [], + contract_addr: {}, + funds: {}, msg: { - update_config: { - duration: unstakingDurationEnabled - ? convertDurationWithUnitsToDuration(unstakingDuration) - : null, - }, + update_config: {}, }, }, }, - }), - [stakingContractAddress] - ) -} - -const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record -) => { - const { stakingContractAddress } = useStakingInfo() + }) && + decodedMessage.wasm.execute.contract_addr === this.stakingContractAddress + ) + } - return objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - update_config: {}, - }, - }, - }, - }) && msg.wasm.execute.contract_addr === stakingContractAddress - ? { - match: true, - data: { - unstakingDurationEnabled: - !!msg.wasm.execute.msg.update_config.duration, - unstakingDuration: msg.wasm.execute.msg.update_config.duration - ? convertDurationToDurationWithUnits( - msg.wasm.execute.msg.update_config.duration - ) - : { - value: 2, - units: DurationUnits.Weeks, - }, - }, - } - : { - match: false, - } + decode([{ decodedMessage }]: ProcessedMessage[]): UpdateStakingConfigData { + return { + unstakingDurationEnabled: + !!decodedMessage.wasm.execute.msg.update_config.duration, + unstakingDuration: decodedMessage.wasm.execute.msg.update_config.duration + ? convertDurationToDurationWithUnits( + decodedMessage.wasm.execute.msg.update_config.duration + ) + : { + value: 2, + units: DurationUnits.Weeks, + }, + } + } } - -export const makeUpdateStakingConfigAction: ActionMaker< - UpdateStakingConfigData -> = ({ t }) => ({ - key: ActionKey.UpdateStakingConfig, - Icon: GearEmoji, - label: t('title.updateStakingConfig'), - description: t('info.updateStakingConfigDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, - notReusable: true, -}) diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/index.ts b/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/index.ts index d3f20b749..40591c371 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/index.ts +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/actions/index.ts @@ -1,4 +1,2 @@ -export * from './MigrateMigalooV4TokenFactory' export * from './Mint' -export * from './UpdateMinterAllowance' export * from './UpdateStakingConfig' diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/hooks/index.ts b/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/hooks/index.ts index 1345985a8..ca93746a8 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/hooks/index.ts +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/hooks/index.ts @@ -1,4 +1,3 @@ -export * from './useCommonGovernanceTokenInfo' export * from './useGovernanceTokenInfo' export * from './useMainDaoInfoCards' export * from './useStakingInfo' diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/hooks/useCommonGovernanceTokenInfo.ts b/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/hooks/useCommonGovernanceTokenInfo.ts deleted file mode 100644 index a1b674e91..000000000 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/hooks/useCommonGovernanceTokenInfo.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useRecoilValue } from 'recoil' - -import { - DaoVotingTokenStakedSelectors, - genericTokenSelector, -} from '@dao-dao/state/recoil' -import { GenericToken, TokenType } from '@dao-dao/types' - -import { useVotingModuleAdapterOptions } from '../../../react/context' - -export const useCommonGovernanceTokenInfo = (): GenericToken => { - const { chainId, votingModuleAddress } = useVotingModuleAdapterOptions() - - const { denom } = useRecoilValue( - DaoVotingTokenStakedSelectors.denomSelector({ - chainId, - contractAddress: votingModuleAddress, - params: [], - }) - ) - - const eitherTokenInfo = useRecoilValue( - genericTokenSelector({ - chainId, - type: TokenType.Native, - denomOrAddress: denom, - }) - ) - - return eitherTokenInfo -} diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/index.ts b/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/index.ts index b23284bc8..1989dbd9d 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/index.ts +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingTokenStaked/index.ts @@ -3,6 +3,7 @@ import { PeopleAltOutlined, PeopleAltRounded } from '@mui/icons-material' import { MainDaoInfoCardsTokenLoader } from '@dao-dao/stateless' import { ActionCategoryKey, + ActionKey, DaoTabId, VotingModuleAdapter, } from '@dao-dao/types' @@ -12,14 +13,9 @@ import { isSecretNetwork, } from '@dao-dao/utils' -import { - makeMigrateMigalooV4TokenFactoryAction, - makeMintAction, - makeUpdateMinterAllowanceAction, - makeUpdateStakingConfigAction, -} from './actions' +import { MintAction, UpdateStakingConfigAction } from './actions' import { MembersTab, ProfileCardMemberInfo, StakingModal } from './components' -import { useCommonGovernanceTokenInfo, useMainDaoInfoCards } from './hooks' +import { useMainDaoInfoCards } from './hooks' export const DaoVotingTokenStakedAdapter: VotingModuleAdapter = { id: DaoVotingTokenStakedAdapterId, @@ -30,7 +26,6 @@ export const DaoVotingTokenStakedAdapter: VotingModuleAdapter = { hooks: { useMainDaoInfoCards, useVotingModuleRelevantAddresses: () => [], - useCommonGovernanceTokenInfo, }, // Components @@ -55,23 +50,16 @@ export const DaoVotingTokenStakedAdapter: VotingModuleAdapter = { // Functions fields: { - actionCategoryMakers: [ - () => ({ - // Add to Commonly Used category. - key: ActionCategoryKey.CommonlyUsed, - actionMakers: [makeMigrateMigalooV4TokenFactoryAction], - }), - () => ({ + actions: { + actions: [MintAction, UpdateStakingConfigAction], + categoryMakers: [ // Add to DAO Governance category. - key: ActionCategoryKey.DaoGovernance, - actionMakers: [ - makeMintAction, - makeUpdateMinterAllowanceAction, - makeUpdateStakingConfigAction, - makeMigrateMigalooV4TokenFactoryAction, - ], - }), - ], + () => ({ + key: ActionCategoryKey.DaoGovernance, + actionKeys: [ActionKey.Mint, ActionKey.UpdateStakingConfig], + }), + ], + }, }, }), } diff --git a/packages/stateful/voting-module-adapter/adapters/Fallback/index.ts b/packages/stateful/voting-module-adapter/adapters/Fallback/index.ts index 5bd8084c3..04b0955cf 100644 --- a/packages/stateful/voting-module-adapter/adapters/Fallback/index.ts +++ b/packages/stateful/voting-module-adapter/adapters/Fallback/index.ts @@ -23,8 +23,6 @@ export const FallbackAdapter: VotingModuleAdapter = { }, // Functions - fields: { - actionCategoryMakers: [], - }, + fields: {}, }), } diff --git a/packages/stateful/voting-module-adapter/adapters/NeutronVotingRegistry/index.ts b/packages/stateful/voting-module-adapter/adapters/NeutronVotingRegistry/index.ts index 9981888d5..a3491c4cc 100644 --- a/packages/stateful/voting-module-adapter/adapters/NeutronVotingRegistry/index.ts +++ b/packages/stateful/voting-module-adapter/adapters/NeutronVotingRegistry/index.ts @@ -38,8 +38,6 @@ export const NeutronVotingRegistryAdapter: VotingModuleAdapter = { }, // Fields - fields: { - actionCategoryMakers: [], - }, + fields: {}, }), } diff --git a/packages/stateful/voting-module-adapter/core.ts b/packages/stateful/voting-module-adapter/core.ts index c09b7d098..902911d39 100644 --- a/packages/stateful/voting-module-adapter/core.ts +++ b/packages/stateful/voting-module-adapter/core.ts @@ -1,4 +1,5 @@ import { + IDaoBase, IVotingModuleAdapterContext, IVotingModuleAdapterOptions, VotingModuleAdapter, @@ -47,22 +48,30 @@ export const matchAdapter = (contractNameToMatch: string) => ) || FallbackAdapter export const matchAndLoadAdapter = ( - contractName: string, - options: IVotingModuleAdapterOptions + dao: IDaoBase ): IVotingModuleAdapterContext => { - const adapter = matchAdapter(contractName) + const adapter = matchAdapter(dao.info.votingModuleInfo.contract) if (!adapter) { throw new Error( - `Failed to find voting module adapter matching contract "${contractName}". Available adapters: ${getAdapters() + `Failed to find voting module adapter matching contract "${ + dao.info.votingModuleInfo.contract + }". Available adapters: ${getAdapters() .map(({ id }) => id) .join(', ')}` ) } + const options: IVotingModuleAdapterOptions = { + chainId: dao.chainId, + votingModuleAddress: dao.info.votingModuleAddress, + coreAddress: dao.coreAddress, + } + return { id: adapter.id, adapter: adapter.load(options), options, + votingModule: dao.votingModule, } } diff --git a/packages/stateful/voting-module-adapter/react/hooks/useCw20CommonGovernanceTokenInfoIfExists.ts b/packages/stateful/voting-module-adapter/react/hooks/useCw20CommonGovernanceTokenInfoIfExists.ts index 6cf48bc57..7cbbd5268 100644 --- a/packages/stateful/voting-module-adapter/react/hooks/useCw20CommonGovernanceTokenInfoIfExists.ts +++ b/packages/stateful/voting-module-adapter/react/hooks/useCw20CommonGovernanceTokenInfoIfExists.ts @@ -1,22 +1,17 @@ import { DaoVotingCw20StakedAdapterId } from '@dao-dao/utils' +import { useDaoGovernanceToken } from '../../../hooks' import { useVotingModuleAdapterContextIfAvailable } from '../context' -// Returns the useCommonGovernanceTokenInfo hook response if using the +// Returns the useDaoGovernanceToken hook response if using the // cw20-staked voting module adapter and within a voting module context. This // will not error if the adapter is unavailable. export const useCw20CommonGovernanceTokenInfoIfExists = () => { - const { - id, - adapter: { - hooks: { useCommonGovernanceTokenInfo }, - }, - } = useVotingModuleAdapterContextIfAvailable() ?? { + const { id } = useVotingModuleAdapterContextIfAvailable() ?? { id: undefined, - adapter: { hooks: {} }, } - const info = useCommonGovernanceTokenInfo?.() + const token = useDaoGovernanceToken() ?? undefined - return id === DaoVotingCw20StakedAdapterId ? info : undefined + return id === DaoVotingCw20StakedAdapterId ? token : undefined } diff --git a/packages/stateful/voting-module-adapter/react/hooks/useCw721CommonGovernanceTokenInfoIfExists.ts b/packages/stateful/voting-module-adapter/react/hooks/useCw721CommonGovernanceTokenInfoIfExists.ts index bf848bce4..62ffa92da 100644 --- a/packages/stateful/voting-module-adapter/react/hooks/useCw721CommonGovernanceTokenInfoIfExists.ts +++ b/packages/stateful/voting-module-adapter/react/hooks/useCw721CommonGovernanceTokenInfoIfExists.ts @@ -1,22 +1,17 @@ import { DaoVotingCw721StakedAdapterId } from '@dao-dao/utils' +import { useDaoGovernanceToken } from '../../../hooks' import { useVotingModuleAdapterContextIfAvailable } from '../context' -// Returns the useGovernanceTokenInfo hook response if using the cw721-staked +// Returns the useDaoGovernanceToken hook response if using the cw721-staked // voting module adapter and within a voting module context. This will not error // if the adapter is unavailable. export const useCw721CommonGovernanceTokenInfoIfExists = () => { - const { - id, - adapter: { - hooks: { useCommonGovernanceTokenInfo }, - }, - } = useVotingModuleAdapterContextIfAvailable() ?? { + const { id } = useVotingModuleAdapterContextIfAvailable() ?? { id: undefined, - adapter: { hooks: {} }, } - const info = useCommonGovernanceTokenInfo?.() + const token = useDaoGovernanceToken() ?? undefined - return id === DaoVotingCw721StakedAdapterId ? info : undefined + return id === DaoVotingCw721StakedAdapterId ? token : undefined } diff --git a/packages/stateful/voting-module-adapter/react/hooks/useNativeCommonGovernanceTokenInfoIfExists.ts b/packages/stateful/voting-module-adapter/react/hooks/useNativeCommonGovernanceTokenInfoIfExists.ts index 5061c643f..c9ee6fcd3 100644 --- a/packages/stateful/voting-module-adapter/react/hooks/useNativeCommonGovernanceTokenInfoIfExists.ts +++ b/packages/stateful/voting-module-adapter/react/hooks/useNativeCommonGovernanceTokenInfoIfExists.ts @@ -1,22 +1,17 @@ import { DaoVotingNativeStakedAdapterId } from '@dao-dao/utils' +import { useDaoGovernanceToken } from '../../../hooks' import { useVotingModuleAdapterContextIfAvailable } from '../context' -// Returns the useGovernanceTokenInfo hook response if using the native-staked +// Returns the useDaoGovernanceToken hook response if using the native-staked // voting module adapter and within a voting module context. This will not error // if the adapter is unavailable. export const useNativeCommonGovernanceTokenInfoIfExists = () => { - const { - id, - adapter: { - hooks: { useCommonGovernanceTokenInfo }, - }, - } = useVotingModuleAdapterContextIfAvailable() ?? { + const { id } = useVotingModuleAdapterContextIfAvailable() ?? { id: undefined, - adapter: { hooks: {} }, } - const info = useCommonGovernanceTokenInfo?.() + const token = useDaoGovernanceToken() - return id === DaoVotingNativeStakedAdapterId ? info : undefined + return id === DaoVotingNativeStakedAdapterId ? token : undefined } diff --git a/packages/stateful/voting-module-adapter/react/provider.tsx b/packages/stateful/voting-module-adapter/react/provider.tsx index de18ab12f..800da2bd0 100644 --- a/packages/stateful/voting-module-adapter/react/provider.tsx +++ b/packages/stateful/voting-module-adapter/react/provider.tsx @@ -1,29 +1,22 @@ import { ReactNode, useState } from 'react' -import { - IVotingModuleAdapterContext, - IVotingModuleAdapterOptions, -} from '@dao-dao/types' +import { useDaoContext } from '@dao-dao/stateless' +import { IVotingModuleAdapterContext } from '@dao-dao/types' import { matchAndLoadAdapter } from '../core' import { VotingModuleAdapterContext } from './context' -export interface VotingModuleAdapterProviderProps { - contractName: string - children: ReactNode | ReactNode[] - options: IVotingModuleAdapterOptions -} - // Ensure this re-renders when the voting module contract name or options // addresses change. You can do this by setting a `key` on this component or one -// of its ancestors. See DaoPageWrapper.tsx where this component is used. +// of its ancestors. See DaoProviders.tsx where this component is used. export const VotingModuleAdapterProvider = ({ - contractName, children, - options, -}: VotingModuleAdapterProviderProps) => { +}: { + children: ReactNode +}) => { + const { dao } = useDaoContext() const [context] = useState(() => - matchAndLoadAdapter(contractName, options) + matchAndLoadAdapter(dao) ) return ( diff --git a/packages/stateful/widgets/react/useWidgets.tsx b/packages/stateful/widgets/react/useWidgets.tsx index 9201a21ab..e19891de2 100644 --- a/packages/stateful/widgets/react/useWidgets.tsx +++ b/packages/stateful/widgets/react/useWidgets.tsx @@ -5,16 +5,12 @@ import { useTranslation } from 'react-i18next' import { useChain, useDaoInfoContext } from '@dao-dao/stateless' import { - DaoWidget, LoadedWidget, LoadingData, WidgetLocation, WidgetVisibilityContext, } from '@dao-dao/types' -import { - getFilteredDaoItemsByPrefix, - getWidgetStorageItemKey, -} from '@dao-dao/utils' +import { getDaoWidgets } from '@dao-dao/utils' import { useMembership } from '../../hooks' import { getWidgetById } from '../core' @@ -36,28 +32,11 @@ export const useWidgets = ({ const { isMember = false } = useMembership() const loadingWidgets = useMemo((): LoadingData => { - const parsedWidgets = getFilteredDaoItemsByPrefix( - items, - getWidgetStorageItemKey('') - ) - .map(([id, widgetJson]): DaoWidget | undefined => { - try { - return { - id, - values: (widgetJson && JSON.parse(widgetJson)) || {}, - } - } catch (err) { - // Ignore widget format error but log to console for debugging. - console.error(`Invalid widget JSON: ${widgetJson}`, err) - return - } - }) - // Validate widget structure. - .filter((widget): widget is DaoWidget => !!widget) + const daoWidgets = getDaoWidgets(items) return { loading: false, - data: parsedWidgets + data: daoWidgets .map((daoWidget): LoadedWidget | undefined => { const widget = getWidgetById(chainId, daoWidget.id) // Enforce location filter. diff --git a/packages/stateful/widgets/widgets/MintNft/MintNftEditor.tsx b/packages/stateful/widgets/widgets/MintNft/MintNftEditor.tsx index cafc21ff7..ccd31777f 100644 --- a/packages/stateful/widgets/widgets/MintNft/MintNftEditor.tsx +++ b/packages/stateful/widgets/widgets/MintNft/MintNftEditor.tsx @@ -19,6 +19,7 @@ import { InputLabel, TextAreaInput, TextInput, + useActionOptions, } from '@dao-dao/stateless' import { WidgetEditorProps } from '@dao-dao/types' import { ContractInfoResponse } from '@dao-dao/types/contracts/Cw721Base' @@ -29,7 +30,6 @@ import { validateRequired, } from '@dao-dao/utils' -import { useActionOptions } from '../../../actions' import { MintNftData } from './types' export const MintNftEditor = ({ diff --git a/packages/stateful/widgets/widgets/Press/Renderer/index.tsx b/packages/stateful/widgets/widgets/Press/Renderer/index.tsx index 650c17152..1dbf4c638 100644 --- a/packages/stateful/widgets/widgets/Press/Renderer/index.tsx +++ b/packages/stateful/widgets/widgets/Press/Renderer/index.tsx @@ -2,11 +2,11 @@ import { useCachedLoading, useDaoInfoContext, useDaoNavHelpers, + useInitializedActionForKey, } from '@dao-dao/stateless' import { ActionKey, WidgetRendererProps } from '@dao-dao/types' import { getDaoProposalSinglePrefill } from '@dao-dao/utils' -import { useActionForKey } from '../../../../actions' import { ButtonLink, IconButtonLink } from '../../../../components' import { useMembership } from '../../../../hooks/useMembership' import { postsSelector } from '../state' @@ -32,24 +32,22 @@ export const Renderer = ({ [] ) - const createPostAction = useActionForKey(ActionKey.CreatePost) - const createPostActionDefaults = createPostAction?.useDefaults() - const updatePostAction = useActionForKey(ActionKey.UpdatePost) - const updatePostActionDefaults = updatePostAction?.useDefaults() - const deletePostAction = useActionForKey(ActionKey.DeletePost) + const createPostAction = useInitializedActionForKey(ActionKey.CreatePost) + const updatePostAction = useInitializedActionForKey(ActionKey.UpdatePost) + const deletePostAction = useInitializedActionForKey(ActionKey.DeletePost) return ( = () => ({ - tokenId: '', - tokenUri: '', - uploaded: false, - data: { - title: '', - description: '', - content: '', - }, -}) - const Component: ActionComponent = (props) => { const { watch } = useFormContext() const tokenId = watch((props.fieldNamePrefix + 'tokenId') as 'tokenId') @@ -59,97 +42,69 @@ const Component: ActionComponent = (props) => { ) } -export const makeCreatePostActionMaker = - ({ - contract, - chainId: configuredChainId, - }: PressData): ActionMaker => - (options) => { - const { - t, - chain: { chain_id: daoChainId }, - } = options +export class CreatePostAction extends ActionBase { + public readonly key = ActionKey.CreatePost + public readonly Component = Component + + protected _defaults: CreatePostData = { + tokenId: '', + tokenUri: '', + uploaded: false, + data: { + title: '', + description: '', + content: '', + }, + } - // The chain that Press is set up on. If chain ID is undefined, default to - // native DAO chain for backwards compatibility. - const pressChainId = configuredChainId || daoChainId + private mintNftAction: MintNftAction - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - let chainId = daoChainId - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - } + constructor(options: ActionOptions, private pressData: PressData) { + super(options, { + Icon: MemoEmoji, + label: options.t('title.createPost'), + description: options.t('info.createPostDescription'), + }) + + this.mintNftAction = new MintNftAction(options) + } - return objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - mint: { - owner: {}, - token_id: {}, - token_uri: {}, - }, - }, - }, - }, - }) && - msg.wasm.execute.contract_addr === contract && - msg.wasm.execute.msg.mint.token_uri - ? { - match: true, - data: { - tokenId: msg.wasm.execute.msg.mint.token_id, - tokenUri: msg.wasm.execute.msg.mint.token_uri, - uploaded: true, - }, - } - : { - match: false, - } + encode({ tokenId, tokenUri }: CreatePostData): UnifiedCosmosMsg[] { + // If chain ID is undefined, default to native DAO chain for backwards + // compatibility. + const pressChainId = this.pressData.chainId || this.options.chain.chain_id + + const owner = getChainAddressForActionOptions(this.options, pressChainId) + if (!owner) { + throw new Error('No minter found for chain.') } - const useTransformToCosmos: UseTransformToCosmos = () => - useCallback( - ({ tokenId, tokenUri }) => - maybeMakePolytoneExecuteMessage( - daoChainId, - pressChainId, - makeWasmMessage({ - wasm: { - execute: { - contract_addr: contract, - funds: [], - msg: { - mint: { - owner: getChainAddressForActionOptions( - options, - pressChainId - ), - token_id: tokenId, - token_uri: tokenUri, - }, - }, - }, - }, - }) - ), - [] - ) + return this.mintNftAction.encode({ + chainId: pressChainId, + collectionAddress: this.pressData.contract, + mintMsg: { + owner, + token_id: tokenId, + token_uri: tokenUri, + }, + // Unused. + contractChosen: true, + }) + } + + match(messages: ProcessedMessage[]): ActionMatch { + return ( + this.mintNftAction.match(messages) && + messages[0].decodedMessage.wasm.execute.contract_addr === + this.pressData.contract + ) + } + decode([{ decodedMessage }]: ProcessedMessage[]): CreatePostData { return { - key: ActionKey.CreatePost, - Icon: MemoEmoji, - label: t('title.createPost'), - description: t('info.createPostDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, + tokenId: decodedMessage.wasm.execute.msg.mint.token_id, + tokenUri: decodedMessage.wasm.execute.msg.mint.token_uri, + uploaded: true, } } +} diff --git a/packages/stateful/widgets/widgets/Press/actions/DeletePost/index.tsx b/packages/stateful/widgets/widgets/Press/actions/DeletePost/index.tsx index a65eb25b6..371ccf2ee 100644 --- a/packages/stateful/widgets/widgets/Press/actions/DeletePost/index.tsx +++ b/packages/stateful/widgets/widgets/Press/actions/DeletePost/index.tsx @@ -1,154 +1,101 @@ -import { useCallback } from 'react' import { useFormContext } from 'react-hook-form' import { constSelector } from 'recoil' -import { TrashEmoji, useCachedLoading } from '@dao-dao/stateless' +import { ActionBase, TrashEmoji, useCachedLoading } from '@dao-dao/stateless' +import { UnifiedCosmosMsg } from '@dao-dao/types' import { ActionComponent, ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, + ActionMatch, + ActionOptions, + ProcessedMessage, } from '@dao-dao/types/actions' -import { - decodePolytoneExecuteMsg, - makeWasmMessage, - maybeMakePolytoneExecuteMessage, - objectMatchesStructure, -} from '@dao-dao/utils' -import { useActionOptions } from '../../../../../actions' +import { BurnNftAction } from '../../../../../actions/core/actions' import { postSelector, postsSelector } from '../../state' import { PressData } from '../../types' import { DeletePostComponent, DeletePostData } from './Component' -const useDefaults: UseDefaults = () => ({ - id: '', -}) - -export const makeDeletePostActionMaker = ({ - chainId: configuredChainId, - contract, -}: PressData): ActionMaker => { - // Make outside of the maker function returned below so it doesn't get - // redefined and thus remounted on every render. - const Component: ActionComponent = (props) => { - const { - chain: { chain_id: daoChainId }, - } = useActionOptions() - // The chain that Press is set up on. If chain ID is undefined, default to - // native DAO chain for backwards compatibility. - const pressChainId = configuredChainId || daoChainId +export class DeletePostAction extends ActionBase { + public readonly key = ActionKey.DeletePost + public readonly Component: ActionComponent - const { watch } = useFormContext() - const id = watch((props.fieldNamePrefix + 'id') as 'id') + protected _defaults: DeletePostData = { + id: '', + } - const postsLoading = useCachedLoading( - postsSelector({ - contractAddress: contract, - chainId: pressChainId, - }), - [] - ) + private burnNftAction: BurnNftAction + private pressChainId: string - // Once created, manually load metadata; it won't be retrievable from - // the contract if it was successfully removed since the token was - // burned. - const postLoading = useCachedLoading( - !props.isCreating - ? postSelector({ - id, - metadataUri: `ipfs://${id}/metadata.json`, - }) - : constSelector(undefined), - undefined - ) + constructor(options: ActionOptions, private pressData: PressData) { + super(options, { + Icon: TrashEmoji, + label: options.t('title.deletePost'), + description: options.t('info.deletePostDescription'), + }) - return ( - - ) - } + this.burnNftAction = new BurnNftAction(options) - return ({ t, chain: { chain_id: daoChainId } }) => { // The chain that Press is set up on. If chain ID is undefined, default to // native DAO chain for backwards compatibility. - const pressChainId = configuredChainId || daoChainId + const pressChainId = pressData.chainId || options.chain.chain_id + this.pressChainId = pressChainId - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - let chainId = daoChainId - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - } + this.Component = function DeletePostActionComponent(props) { + const { watch } = useFormContext() + const id = watch((props.fieldNamePrefix + 'id') as 'id') - return objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - burn: { - token_id: {}, - }, - }, - }, - }, - }) && - chainId === pressChainId && - msg.wasm.execute.contract_addr === contract - ? { - match: true, - data: { - id: msg.wasm.execute.msg.burn.token_id, - }, - } - : { - match: false, - } - } + const postsLoading = useCachedLoading( + postsSelector({ + contractAddress: pressData.contract, + chainId: pressChainId, + }), + [] + ) - const useTransformToCosmos: UseTransformToCosmos = () => - useCallback( - ({ id }) => - maybeMakePolytoneExecuteMessage( - daoChainId, - pressChainId, - makeWasmMessage({ - wasm: { - execute: { - contract_addr: contract, - funds: [], - msg: { - burn: { - token_id: id, - }, - }, - }, - }, + // Once created, manually load metadata; it won't be retrievable from the + // contract if it was successfully removed since the token was burned. + const postLoading = useCachedLoading( + !props.isCreating + ? postSelector({ + id, + metadataUri: `ipfs://${id}/metadata.json`, }) - ), - [] + : constSelector(undefined), + undefined ) + return ( + + ) + } + } + + encode({ id }: DeletePostData): UnifiedCosmosMsg[] { + return this.burnNftAction.encode({ + chainId: this.pressChainId, + collection: this.pressData.contract, + tokenId: id, + }) + } + + match(messages: ProcessedMessage[]): ActionMatch { + return ( + this.burnNftAction.match(messages) && + messages[0].decodedMessage.wasm.execute.contract_addr === + this.pressData.contract + ) + } + + decode([{ decodedMessage }]: ProcessedMessage[]): DeletePostData { return { - key: ActionKey.DeletePost, - Icon: TrashEmoji, - label: t('title.deletePost'), - description: t('info.deletePostDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, + id: decodedMessage.wasm.execute.msg.burn.token_id, } } } diff --git a/packages/stateful/widgets/widgets/Press/actions/UpdatePost/Component.tsx b/packages/stateful/widgets/widgets/Press/actions/UpdatePost/Component.tsx index 15b4119f2..e3e440f61 100644 --- a/packages/stateful/widgets/widgets/Press/actions/UpdatePost/Component.tsx +++ b/packages/stateful/widgets/widgets/Press/actions/UpdatePost/Component.tsx @@ -15,7 +15,7 @@ import { TextAreaInput, TextInput, } from '@dao-dao/stateless' -import { ActionComponent, ActionKey, LoadingData } from '@dao-dao/types' +import { ActionComponent, LoadingData } from '@dao-dao/types' import { processError, transformIpfsUrlToHttpsIfNecessary, @@ -49,8 +49,6 @@ export const UpdatePostComponent: ActionComponent = ({ errors, isCreating, options: { postLoading, postsLoading }, - addAction, - allActionsWithData, }) => { const { t } = useTranslation() const { register, watch, setValue } = useFormContext() @@ -127,21 +125,6 @@ export const UpdatePostComponent: ActionComponent = ({ setValue((fieldNamePrefix + 'tokenId') as 'tokenId', cid) setValue((fieldNamePrefix + 'tokenUri') as 'tokenUri', metadataUrl) setValue((fieldNamePrefix + 'uploaded') as 'uploaded', true) - - // Add action to delete old post if does not already exist. - if ( - !allActionsWithData.some( - ({ actionKey, data }) => - actionKey === ActionKey.DeletePost && data.id === updatingPost.id - ) - ) { - addAction?.({ - actionKey: ActionKey.DeletePost, - data: { - id: updatingPost.id, - }, - }) - } } catch (err) { console.error(err) toast.error(processError(err)) diff --git a/packages/stateful/widgets/widgets/Press/actions/UpdatePost/index.tsx b/packages/stateful/widgets/widgets/Press/actions/UpdatePost/index.tsx index 76d588849..e5bc3d107 100644 --- a/packages/stateful/widgets/widgets/Press/actions/UpdatePost/index.tsx +++ b/packages/stateful/widgets/widgets/Press/actions/UpdatePost/index.tsx @@ -1,176 +1,128 @@ -import { useCallback } from 'react' import { useFormContext } from 'react-hook-form' -import { PencilEmoji, useCachedLoading } from '@dao-dao/stateless' +import { ActionBase, PencilEmoji, useCachedLoading } from '@dao-dao/stateless' +import { UnifiedCosmosMsg } from '@dao-dao/types' import { ActionComponent, ActionKey, - ActionMaker, - UseDecodedCosmosMsg, - UseDefaults, - UseTransformToCosmos, + ActionMatch, + ActionOptions, + ProcessedMessage, } from '@dao-dao/types/actions' -import { - decodePolytoneExecuteMsg, - getChainAddressForActionOptions, - makeWasmMessage, - maybeMakePolytoneExecuteMessage, - objectMatchesStructure, -} from '@dao-dao/utils' - -import { useActionOptions } from '../../../../../actions' + import { postSelector, postsSelector } from '../../state' import { PressData } from '../../types' +import { CreatePostAction } from '../CreatePost' +import { DeletePostAction } from '../DeletePost' import { UpdatePostComponent, UpdatePostData } from './Component' -const useDefaults: UseDefaults = () => ({ - tokenId: '', - tokenUri: '', - uploaded: false, - data: { - title: '', - description: '', - content: '', - }, -}) - -export const makeUpdatePostActionMaker = ({ - chainId: configuredChainId, - contract, -}: PressData): ActionMaker => { - // Make outside of the maker function returned below so it doesn't get - // redefined and thus remounted on every render. - const Component: ActionComponent = (props) => { - const { - chain: { chain_id: daoChainId }, - } = useActionOptions() - // The chain that Press is set up on. If chain ID is undefined, default to - // native DAO chain for backwards compatibility. - const pressChainId = configuredChainId || daoChainId - - const { watch } = useFormContext() - const tokenId = watch((props.fieldNamePrefix + 'tokenId') as 'tokenId') - const tokenUri = watch((props.fieldNamePrefix + 'tokenUri') as 'tokenUri') - const uploaded = watch((props.fieldNamePrefix + 'uploaded') as 'uploaded') - - const postLoading = useCachedLoading( - uploaded && tokenId && tokenUri - ? postSelector({ - id: tokenId, - metadataUri: tokenUri, - }) - : undefined, - undefined - ) - - const postsLoading = useCachedLoading( - postsSelector({ - contractAddress: contract, - chainId: pressChainId, - }), - [] - ) - - return ( - - ) +export class UpdatePostAction extends ActionBase { + public readonly key = ActionKey.UpdatePost + public readonly Component: ActionComponent + + protected _defaults: UpdatePostData = { + tokenId: '', + tokenUri: '', + uploaded: false, + data: { + title: '', + description: '', + content: '', + }, } - return (options) => { - const { - t, - chain: { chain_id: daoChainId }, - } = options - - // The chain that Press is set up on. If chain ID is undefined, default to - // native DAO chain for backwards compatibility. - const pressChainId = configuredChainId || daoChainId - - const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( - msg: Record - ) => { - let chainId = daoChainId - const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) - if (decodedPolytone.match) { - chainId = decodedPolytone.chainId - msg = decodedPolytone.msg - } - - return objectMatchesStructure(msg, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - mint: { - owner: {}, - token_id: {}, - token_uri: {}, - }, - }, - }, - }, - }) && - chainId === pressChainId && - msg.wasm.execute.contract_addr === contract && - msg.wasm.execute.msg.mint.token_uri - ? { - match: true, - data: { - chainId, - tokenId: msg.wasm.execute.msg.mint.token_id, - tokenUri: msg.wasm.execute.msg.mint.token_uri, - uploaded: true, - }, - } - : { - match: false, - } - } + private createPostAction: CreatePostAction + private deletePostAction: DeletePostAction + + constructor(options: ActionOptions, private pressData: PressData) { + super(options, { + Icon: PencilEmoji, + label: options.t('title.updatePost'), + description: options.t('info.updatePostDescription'), + }) - const useTransformToCosmos: UseTransformToCosmos = () => - useCallback( - ({ tokenId, tokenUri }) => - maybeMakePolytoneExecuteMessage( - daoChainId, - pressChainId, - makeWasmMessage({ - wasm: { - execute: { - contract_addr: contract, - funds: [], - msg: { - mint: { - owner: getChainAddressForActionOptions( - options, - pressChainId - ), - token_id: tokenId, - token_uri: tokenUri, - }, - }, - }, - }, + this.createPostAction = new CreatePostAction(options, pressData) + this.deletePostAction = new DeletePostAction(options, pressData) + + this.Component = function UpdatePostActionComponent(props) { + const { watch } = useFormContext() + const tokenId = watch((props.fieldNamePrefix + 'tokenId') as 'tokenId') + const tokenUri = watch((props.fieldNamePrefix + 'tokenUri') as 'tokenUri') + const uploaded = watch((props.fieldNamePrefix + 'uploaded') as 'uploaded') + + const postLoading = useCachedLoading( + uploaded && tokenId && tokenUri + ? postSelector({ + id: tokenId, + metadataUri: tokenUri, }) - ), + : undefined, + undefined + ) + + const postsLoading = useCachedLoading( + postsSelector({ + contractAddress: pressData.contract, + // The chain that Press is set up on. If chain ID is undefined, + // default to native DAO chain for backwards compatibility. + chainId: pressData.chainId || options.chain.chain_id, + }), [] ) + return ( + + ) + } + } + + encode({ updateId, tokenId, tokenUri }: UpdatePostData): UnifiedCosmosMsg[] { + if (!updateId) { + throw new Error('No post chosen.') + } + + return [ + ...this.createPostAction.encode({ + tokenId, + tokenUri, + // Unused. + uploaded: true, + }), + ...this.deletePostAction.encode({ + id: updateId, + }), + ] + } + + match(messages: ProcessedMessage[]): ActionMatch { + const orderCorrect = + messages.length >= 2 && + this.createPostAction.match([messages[0]]) && + this.deletePostAction.match([messages[1]]) + + if (!orderCorrect) { + return false + } + + const createPost = this.createPostAction.decode([messages[0]]) + const deletePost = this.deletePostAction.decode([messages[1]]) + return createPost.tokenId === deletePost.id + } + + decode(messages: ProcessedMessage[]): UpdatePostData { + const createPost = this.createPostAction.decode([messages[0]]) + const deletePost = this.deletePostAction.decode([messages[1]]) return { - key: ActionKey.UpdatePost, - Icon: PencilEmoji, - label: t('title.updatePost'), - description: t('info.updatePostDescription'), - Component, - useDefaults, - useTransformToCosmos, - useDecodedCosmosMsg, + updateId: deletePost.id, + tokenId: createPost.tokenId, + tokenUri: createPost.tokenUri, + uploaded: true, } } } diff --git a/packages/stateful/widgets/widgets/Press/index.ts b/packages/stateful/widgets/widgets/Press/index.ts index cd1054ce2..3d5b0463f 100644 --- a/packages/stateful/widgets/widgets/Press/index.ts +++ b/packages/stateful/widgets/widgets/Press/index.ts @@ -2,6 +2,7 @@ import { ArticleOutlined, ArticleRounded } from '@mui/icons-material' import { ActionCategoryKey, + ActionKey, Widget, WidgetId, WidgetLocation, @@ -9,9 +10,9 @@ import { } from '@dao-dao/types' import { mustGetSupportedChainConfig } from '@dao-dao/utils' -import { makeCreatePostActionMaker } from './actions/CreatePost' -import { makeDeletePostActionMaker } from './actions/DeletePost' -import { makeUpdatePostActionMaker } from './actions/UpdatePost' +import { CreatePostAction } from './actions/CreatePost' +import { DeletePostAction } from './actions/DeletePost' +import { UpdatePostAction } from './actions/UpdatePost' import { PressEditor as Editor } from './PressEditor' import { Renderer } from './Renderer' import { PressData } from './types' @@ -27,22 +28,24 @@ export const PressWidget: Widget = { // Must have cw721 base to mint NFTs. isChainSupported: (chainId) => (mustGetSupportedChainConfig(chainId).codeIds.Cw721Base ?? 0) > 0, - getActionCategoryMakers: (data) => { - // Make makers in outer function so they're not remade on every render. - const actionMakers = [ - makeCreatePostActionMaker(data), - makeUpdatePostActionMaker(data), - makeDeletePostActionMaker(data), - ] - - return [ + getActions: (pressData) => ({ + actionMakers: [ + (options) => new CreatePostAction(options, pressData), + (options) => new UpdatePostAction(options, pressData), + (options) => new DeletePostAction(options, pressData), + ], + categoryMakers: [ ({ t }) => ({ key: ActionCategoryKey.Press, label: t('actionCategory.pressLabel'), description: t('actionCategory.pressDescription'), keywords: ['publish', 'article', 'news', 'announcement'], - actionMakers, + actionKeys: [ + ActionKey.CreatePost, + ActionKey.UpdatePost, + ActionKey.DeletePost, + ], }), - ] - }, + ], + }), } diff --git a/packages/stateful/widgets/widgets/VestingPayments/Renderer/TabRenderer/index.tsx b/packages/stateful/widgets/widgets/VestingPayments/Renderer/TabRenderer/index.tsx index 4367988be..05e9008fa 100644 --- a/packages/stateful/widgets/widgets/VestingPayments/Renderer/TabRenderer/index.tsx +++ b/packages/stateful/widgets/widgets/VestingPayments/Renderer/TabRenderer/index.tsx @@ -4,7 +4,11 @@ import { cwPayrollFactoryExtraQueries, cwVestingExtraQueries, } from '@dao-dao/state/query' -import { useDaoInfoContext, useDaoNavHelpers } from '@dao-dao/stateless' +import { + useDaoInfoContext, + useDaoNavHelpers, + useInitializedActionForKey, +} from '@dao-dao/stateless' import { ActionKey, VestingPaymentsWidgetData, @@ -15,7 +19,6 @@ import { makeCombineQueryResultsIntoLoadingDataWithError, } from '@dao-dao/utils' -import { useActionForKey } from '../../../../../actions' import { ButtonLink, Trans, @@ -87,8 +90,7 @@ export const TabRenderer = ({ }), }) - const vestingAction = useActionForKey(ActionKey.ManageVesting) - const vestingActionDefaults = vestingAction?.useDefaults() + const vestingAction = useInitializedActionForKey(ActionKey.ManageVesting) // Vesting payments that need a slash registered. const vestingPaymentsNeedingSlashRegistration = @@ -105,13 +107,13 @@ export const TabRenderer = ({ VestingPaymentCard={VestingPaymentCard} VestingPaymentLine={VestingPaymentLine} createVestingPaymentHref={ - vestingAction + !vestingAction.loading && !vestingAction.errored ? getDaoProposalPath(coreAddress, 'create', { prefill: getDaoProposalSinglePrefill({ actions: [ { - actionKey: vestingAction.key, - data: vestingActionDefaults, + actionKey: vestingAction.data.key, + data: vestingAction.data.defaults, }, ], }), @@ -120,7 +122,9 @@ export const TabRenderer = ({ } isMember={isMember} registerSlashesHref={ - vestingAction && vestingPaymentsNeedingSlashRegistration.length > 0 + !vestingAction.loading && + !vestingAction.errored && + vestingPaymentsNeedingSlashRegistration.length > 0 ? getDaoProposalPath(coreAddress, 'create', { prefill: getDaoProposalSinglePrefill({ actions: vestingPaymentsNeedingSlashRegistration.flatMap( @@ -134,9 +138,9 @@ export const TabRenderer = ({ slashes .filter((slash) => slash.unregisteredAmount > 0) .map((slash) => ({ - actionKey: vestingAction.key, + actionKey: vestingAction.data.key, data: { - ...vestingActionDefaults, + ...vestingAction.data.defaults, mode: 'registerSlash', registerSlash: { chainId, diff --git a/packages/stateful/widgets/widgets/VestingPayments/VestingPaymentsEditor.tsx b/packages/stateful/widgets/widgets/VestingPayments/VestingPaymentsEditor.tsx index 535ac5569..fd2845a3f 100644 --- a/packages/stateful/widgets/widgets/VestingPayments/VestingPaymentsEditor.tsx +++ b/packages/stateful/widgets/widgets/VestingPayments/VestingPaymentsEditor.tsx @@ -124,7 +124,7 @@ const VestingFactoryChain = ({ }: VestingFactoryChainProps) => { const { t } = useTranslation() const nativeChainId = - props.accounts.find((a) => a.type === AccountType.Native)?.chainId || + props.accounts.find((a) => a.type === AccountType.Base)?.chainId || props.accounts[0].chainId const daoChainAccountAddress = getAccountAddress({ accounts: props.accounts, diff --git a/packages/stateless/actions/ActionBase.ts b/packages/stateless/actions/ActionBase.ts new file mode 100644 index 000000000..6511369ca --- /dev/null +++ b/packages/stateless/actions/ActionBase.ts @@ -0,0 +1,138 @@ +import { + Action, + ActionComponent, + ActionEncodeContext, + ActionKey, + ActionMatch, + ActionOptions, + ProcessedMessage, + UnifiedCosmosMsg, +} from '@dao-dao/types' + +export abstract class ActionBase< + Data extends Record = Record +> implements Action +{ + public abstract readonly key: ActionKey + public abstract Component: ActionComponent + + protected _status: 'idle' | 'loading' | 'error' | 'ready' = 'idle' + protected _error?: Error + protected _defaults?: Data + protected _metadata: Action['metadata'] + + private _initPromise: Promise | undefined + + constructor( + public readonly options: ActionOptions, + metadata: Action['metadata'] + ) { + this._metadata = metadata + } + + /** + * Calls `setup` and sets status accordingly. Throws error if defaults are not + * successfully set by the end and thus the action is not ready. + */ + async init() { + // If already ready, do nothing. + if (this.ready) { + return + } + + // If _initPromise does not yet exist, perform setup. + if (!this._initPromise) { + this._initPromise = new Promise(async (resolve, reject) => { + this._status = 'loading' + this._error = undefined + + try { + await this.setup() + + // Verify defaults are set. If not, throw an error. + if (this.defaults) { + this._status = 'ready' + this._initPromise = undefined + resolve() + } else { + throw new Error( + `No defaults provided for action with key ${this.key}` + ) + } + } catch (error) { + this._error = error instanceof Error ? error : new Error(`${error}`) + this._status = 'error' + this._initPromise = undefined + reject(this._error) + } + }) + } + + return this._initPromise + } + + /** + * Actions should override this to load any data needed for encoding/decoding + * and set defaults. `init` wraps this and sets the status accordingly. + * + * By default, do nothing, in case the action sets constant defaults using the + * instance variable `_defaults` and needs no other setup. + */ + setup(): void | Promise {} + + /** + * Allow setting defaults only inside of the class (for use in `setup`). + */ + protected set defaults(data: Data) { + this._defaults = data + } + + get metadata() { + return this._metadata + } + + get status() { + return this._status + } + + get loading() { + return this._status === 'loading' + } + + get errored() { + return this._status === 'error' + } + + get ready() { + return this._status === 'ready' + } + + get error() { + return this._error + } + + get defaults(): Data { + if (!this._defaults) { + throw new Error('Defaults not loaded') + } + return this._defaults + } + + abstract encode( + data: Data, + context: ActionEncodeContext + ): + | UnifiedCosmosMsg + | UnifiedCosmosMsg[] + | Promise + + abstract match( + messages: ProcessedMessage[] + ): ActionMatch | Promise + + abstract decode( + messages: ProcessedMessage[] + ): Partial | Promise> + + transformImportData?(data: any): Data +} diff --git a/packages/stateless/actions/ActionDecoder.ts b/packages/stateless/actions/ActionDecoder.ts new file mode 100644 index 000000000..46ba6efb9 --- /dev/null +++ b/packages/stateless/actions/ActionDecoder.ts @@ -0,0 +1,73 @@ +import cloneDeep from 'lodash.clonedeep' + +import { Action, IActionDecoder, ProcessedMessage } from '@dao-dao/types' + +export class ActionDecoder< + Data extends Record = Record +> implements IActionDecoder +{ + private _status: 'idle' | 'loading' | 'error' | 'ready' = 'idle' + private _error?: Error + private _data?: Data + + constructor( + public readonly action: Action, + public readonly messages: ProcessedMessage[] + ) {} + + get status() { + return this._status + } + + get data() { + if (this._status !== 'ready' || !this._data) { + throw new Error('Decoder not ready') + } + + return this._data + } + + get loading() { + return this._status === 'loading' + } + + get errored() { + return this._status === 'error' + } + + get ready() { + return this._status === 'ready' + } + + get error() { + if (this._status !== 'error' || !this._error) { + throw new Error('Decoder did not error') + } + + return this._error + } + + async decode(): Promise { + this._status = 'loading' + this._error = undefined + this._data = undefined + + try { + await this.action.init() + + const decoded = await this.action.decode(this.messages) + + this._data = { + ...cloneDeep(this.action.defaults), + ...decoded, + } + this._status = 'ready' + + return this._data + } catch (error) { + this._error = error instanceof Error ? error : new Error(`${error}`) + this._status = 'error' + throw error + } + } +} diff --git a/packages/stateless/actions/ActionMatcher.ts b/packages/stateless/actions/ActionMatcher.ts new file mode 100644 index 000000000..64d7716d5 --- /dev/null +++ b/packages/stateless/actions/ActionMatcher.ts @@ -0,0 +1,137 @@ +import { + Action, + ActionOptions, + IActionMatcher, + MessageProcessor, + UnifiedCosmosMsg, +} from '@dao-dao/types' + +import { ActionDecoder } from './ActionDecoder' + +export class ActionMatcher implements IActionMatcher { + private _actions: readonly Action[] = [] + + private _status: 'idle' | 'loading' | 'error' | 'ready' = 'idle' + private _error?: Error + private _matches?: ActionDecoder[] + + constructor( + public options: ActionOptions, + public messageProcessor: MessageProcessor, + actions: Action[] + ) { + this.actions = actions + } + + set actions(actions: Action[]) { + // Sort by match priority. + this._actions = [...actions].sort( + (a, b) => + (b.metadata.matchPriority ?? 0) - (a.metadata.matchPriority ?? 0) + ) + } + + get status() { + return this._status + } + + get matches() { + if (!this.ready || !this._matches) { + throw new Error('Matcher not ready') + } + + return this._matches + } + + get idle() { + return this._status === 'idle' + } + + get loading() { + return this._status === 'loading' + } + + get errored() { + return this._status === 'error' + } + + get ready() { + return this._status === 'ready' + } + + get error() { + if (this._status !== 'error') { + throw new Error('Matcher did not error') + } + + return this._error || new Error('Unknown matcher error') + } + + async match(messages: UnifiedCosmosMsg[]): Promise { + this._status = 'loading' + this._error = undefined + this._matches = undefined + + try { + const matches: ActionDecoder[] = [] + + const processedMessages = await Promise.all( + messages.map((message) => + this.messageProcessor({ + chainId: this.options.chain.chain_id, + sender: this.options.address, + message, + queryClient: this.options.queryClient, + }) + ) + ) + + // Iterate through all messages, greedily matching actions. + let index = 0 + while (index < processedMessages.length) { + const matched = ( + await Promise.allSettled( + this._actions.map(async (action) => { + await action.init() + + return { + action, + match: await action.match(processedMessages.slice(index)), + } + }) + ) + ).flatMap((p) => + p.status === 'fulfilled' && p.value.match + ? { + action: p.value.action, + match: p.value.match, + } + : [] + )[0] + + // There should always be a match since Custom matches all. + if (matched) { + const count = matched.match === true ? 1 : matched.match + matches.push( + new ActionDecoder( + matched.action, + processedMessages.slice(index, index + count) + ) + ) + index += count + } else { + throw new Error('No match found for message.') + } + } + + this._matches = matches + this._status = 'ready' + + return this._matches + } catch (error) { + this._error = error instanceof Error ? error : new Error(`${error}`) + this._status = 'error' + throw error + } + } +} diff --git a/packages/stateless/actions/ActionsEncoder.ts b/packages/stateless/actions/ActionsEncoder.ts new file mode 100644 index 000000000..427b469cb --- /dev/null +++ b/packages/stateless/actions/ActionsEncoder.ts @@ -0,0 +1,119 @@ +import { + Action, + ActionEncodeContext, + ActionKey, + ActionKeyAndDataNoId, + IActionsEncoder, + UnifiedCosmosMsg, +} from '@dao-dao/types' + +export class ActionsEncoder implements IActionsEncoder { + private actionMap: Record + + private _status: 'idle' | 'loading' | 'error' | 'ready' = 'idle' + private _error?: Error + private _messages?: UnifiedCosmosMsg[] + + constructor(private encodeContext: ActionEncodeContext, actions: Action[]) { + this.actionMap = actions.reduce((acc, action) => { + acc[action.key] = action + return acc + }, {} as Record) + } + + get status() { + return this._status + } + + get messages() { + if (this._status !== 'ready' || !this._messages) { + throw new Error('Encoder not ready') + } + + return this._messages + } + + get idle() { + return this._status === 'idle' + } + + get loading() { + return this._status === 'loading' + } + + get errored() { + return this._status === 'error' + } + + get ready() { + return this._status === 'ready' + } + + get error() { + if (this._status !== 'error' || !this._error) { + throw new Error('Matcher did not error') + } + + return this._error + } + + async encode( + actionKeysAndData: ActionKeyAndDataNoId[] + ): Promise { + this._status = 'loading' + this._error = undefined + this._messages = undefined + + try { + const encoded = ( + await Promise.all( + actionKeysAndData.map(async ({ actionKey, data }, index) => { + // If no action key, skip it. + if (!actionKey) { + return [] + } + + // If no data, throw error because this is invalidly selected. + if (!data) { + throw new Error( + `No action data for action ${index + 1} with key ${actionKey}.` + ) + } + + const action = this.actionMap[actionKey] + // If no action found, skip it. This may occur if using a cached + // action key that no longer exists. + if (!action) { + return [] + } + + try { + await action.init() + + return await action.encode(data, this.encodeContext) + } catch (error) { + throw new Error( + `Error from action ${index + 1} with key ${action.key}: ${ + error instanceof Error ? error.message : error + }`, + { + cause: error, + } + ) + } + }) + ) + ).flat() + + this._messages = encoded + this._status = 'ready' + + return this._messages + } catch (error) { + console.error('ActionsEncoder error', error) + this._error = error instanceof Error ? error : new Error(`${error}`) + this._status = 'error' + throw error + } + } +} diff --git a/packages/stateless/actions/index.ts b/packages/stateless/actions/index.ts new file mode 100644 index 000000000..362d2a44a --- /dev/null +++ b/packages/stateless/actions/index.ts @@ -0,0 +1,4 @@ +export * from './ActionBase' +export * from './ActionDecoder' +export * from './ActionMatcher' +export * from './ActionsEncoder' diff --git a/packages/stateless/components/CosmosMessageDisplay.tsx b/packages/stateless/components/CosmosMessageDisplay.tsx index db518b6c0..32e681b2d 100644 --- a/packages/stateless/components/CosmosMessageDisplay.tsx +++ b/packages/stateless/components/CosmosMessageDisplay.tsx @@ -6,6 +6,7 @@ import clsx from 'clsx' import dynamic from 'next/dynamic' import { Theme, useThemeContext } from '../theme' +import { Loader } from './logo' const CodeMirror = dynamic( () => import('react-codemirror2').then((module) => module.UnControlled), @@ -21,32 +22,39 @@ if (typeof window !== 'undefined' && typeof window.navigator !== 'undefined') { export interface CosmosMessageDisplayProps { value: string + loading?: boolean className?: string } export const CosmosMessageDisplay = ({ value, + loading, className, }: CosmosMessageDisplayProps) => { const themeCtx = useThemeContext() const editorTheme = themeCtx.theme !== Theme.Dark ? 'default' : 'material-ocean' + return (
- + {loading ? ( + + ) : ( + + )}
) } diff --git a/packages/stateless/components/IbcDestinationChainPicker.tsx b/packages/stateless/components/IbcDestinationChainPicker.tsx index d8aca0d3e..4aee2ee45 100644 --- a/packages/stateless/components/IbcDestinationChainPicker.tsx +++ b/packages/stateless/components/IbcDestinationChainPicker.tsx @@ -2,12 +2,7 @@ import { useMemo } from 'react' import { useDeepCompareMemoize } from 'use-deep-compare-effect' import { ChainPickerPopupProps } from '@dao-dao/types' -import { - getChainForChainId, - getChainForChainName, - ibc, - maybeGetChainForChainName, -} from '@dao-dao/utils' +import { getIbcTransferChainIdsForChain } from '@dao-dao/utils' import { ChainPickerPopup } from './popup' @@ -39,40 +34,16 @@ export const IbcDestinationChainPicker = ({ ...pickerProps }: IbcDestinationChainPickerProps) => { const chainIds = useMemo(() => { - const sourceChain = getChainForChainId(sourceChainId) - const allChainIds = [ // Source chain. - ...(includeSourceChain ? [sourceChain.chain_id] : []), + ...(includeSourceChain ? [sourceChainId] : []), // IBC destination chains. - ...ibc - .filter( - ({ chain_1, chain_2, channels }) => - // Either chain is the source chain. - (chain_1.chain_name === sourceChain.chain_name || - chain_2.chain_name === sourceChain.chain_name) && - // Both chains exist in the registry. - maybeGetChainForChainName(chain_1.chain_name) && - maybeGetChainForChainName(chain_2.chain_name) && - // An ics20 transfer channel exists. - channels.some( - ({ chain_1, chain_2, version }) => - version === 'ics20-1' && - chain_1.port_id === 'transfer' && - chain_2.port_id === 'transfer' - ) - ) - .map(({ chain_1, chain_2 }) => { - const otherChain = - chain_1.chain_name === sourceChain.chain_name ? chain_2 : chain_1 - return getChainForChainName(otherChain.chain_name).chain_id - }) - // Remove nonexistent osmosis testnet chain. - .filter((chainId) => chainId !== 'osmo-test-4'), + ...getIbcTransferChainIdsForChain(sourceChainId), ].filter((chainId) => !excludeChainIds?.includes(chainId)) // Remove duplicates and sort. return Array.from(new Set(allChainIds)).sort((a, b) => a.localeCompare(b)) + // eslint-disable-next-line react-hooks/exhaustive-deps }, useDeepCompareMemoize([excludeChainIds, includeSourceChain, sourceChainId])) diff --git a/packages/stateless/components/PayEntityDisplay.tsx b/packages/stateless/components/PayEntityDisplay.tsx index a7775f54c..957912b5f 100644 --- a/packages/stateless/components/PayEntityDisplay.tsx +++ b/packages/stateless/components/PayEntityDisplay.tsx @@ -4,10 +4,12 @@ import { } 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' +import { useDetectWrap } from '../hooks' +import { TokenAmountDisplay } from './token' + export const PayEntityDisplay = ({ tokens, className, diff --git a/packages/stateless/components/TokenSwapStatus.tsx b/packages/stateless/components/TokenSwapStatus.tsx index 9e9dea9ef..9e280f0be 100644 --- a/packages/stateless/components/TokenSwapStatus.tsx +++ b/packages/stateless/components/TokenSwapStatus.tsx @@ -7,9 +7,10 @@ import { import clsx from 'clsx' import { useTranslation } from 'react-i18next' -import { TokenAmountDisplay } from '@dao-dao/stateless' import { TokenSwapStatusProps } from '@dao-dao/types/components/TokenSwapStatus' +import { TokenAmountDisplay } from './token' + export const TokenSwapStatus = ({ selfParty, counterparty, diff --git a/packages/stateless/components/ValidatorPicker.tsx b/packages/stateless/components/ValidatorPicker.tsx index e65acee45..8f44abd73 100644 --- a/packages/stateless/components/ValidatorPicker.tsx +++ b/packages/stateless/components/ValidatorPicker.tsx @@ -3,15 +3,6 @@ import clsx from 'clsx' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { - Button, - CopyToClipboard, - FilterableItemPopup, - IconButtonLink, - InputThemedText, - TokenAmountDisplay, - Tooltip, -} from '@dao-dao/stateless' import { PopupTriggerCustomComponent, ValidatorPickerProps, @@ -21,6 +12,14 @@ import { formatPercentOf100, } from '@dao-dao/utils' +import { Button } from './buttons' +import { CopyToClipboard } from './CopyToClipboard' +import { IconButtonLink } from './icon_buttons' +import { InputThemedText } from './inputs' +import { FilterableItemPopup } from './popup' +import { TokenAmountDisplay } from './token' +import { Tooltip } from './tooltip' + export const ValidatorPicker = ({ validators, stakes, diff --git a/packages/stateless/components/actions/ActionCard.tsx b/packages/stateless/components/actions/ActionCard.tsx index 12697c307..fdfd1af11 100644 --- a/packages/stateless/components/actions/ActionCard.tsx +++ b/packages/stateless/components/actions/ActionCard.tsx @@ -1,15 +1,13 @@ import { Close } from '@mui/icons-material' import clsx from 'clsx' import { ReactNode } from 'react' -import { useTranslation } from 'react-i18next' import { Action } from '@dao-dao/types' import { IconButton } from '../icon_buttons' export type ActionCardProps = { - action: Action - actionCount: number + action: Action onRemove?: () => void childrenContainerClassName?: string children: ReactNode | ReactNode[] @@ -17,53 +15,43 @@ export type ActionCardProps = { export const ActionCard = ({ action, - actionCount, onRemove, childrenContainerClassName, children, -}: ActionCardProps) => { - const { t } = useTranslation() +}: ActionCardProps) => ( +
+
+
+

+ +

- return ( -
-
-
-

- -

- -
-

{action.label}

- - {actionCount > 1 && ( -

- ({t('info.actions', { count: actionCount })}) -

- )} -
-
- - { - // Don't allow removing programmatic actions. - onRemove && !action?.programmaticOnly && ( - - ) - } +

{action.metadata.label}

-
- {children} -
+ { + // Don't allow removing programmatic actions. + onRemove && !action?.metadata.programmaticOnly && ( + + ) + }
- ) -} + +
+ {children} +
+
+) export const ActionCardLoader = () => (
diff --git a/packages/stateless/components/actions/ActionLibrary.tsx b/packages/stateless/components/actions/ActionLibrary.tsx index 7ba54b629..a15d71e67 100644 --- a/packages/stateless/components/actions/ActionLibrary.tsx +++ b/packages/stateless/components/actions/ActionLibrary.tsx @@ -2,21 +2,22 @@ import { Star, WarningRounded } from '@mui/icons-material' import clsx from 'clsx' import Fuse from 'fuse.js' import cloneDeep from 'lodash.clonedeep' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useFieldArray, useFormContext } from 'react-hook-form' +import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' import { v4 as uuidv4 } from 'uuid' import { Action, - ActionCategory, ActionCategoryKey, ActionKey, ActionKeyAndData, - LoadedActions, } from '@dao-dao/types' +import { processError } from '@dao-dao/utils' -import { useSearchFilter, useUpdatingRef } from '../../hooks' +import { useActionsContext } from '../../contexts' +import { useLoadingPromise, useSearchFilter, useUpdatingRef } from '../../hooks' import { Button } from '../buttons' import { Collapsible } from '../Collapsible' import { SearchBar } from '../inputs' @@ -25,16 +26,6 @@ import { NoContent } from '../NoContent' import { TooltipInfoIcon } from '../tooltip' export type ActionLibraryProps = { - /** - * All action categories to render. Should be loaded from - * `useLoadedActionsAndCategories`. - */ - categories: ActionCategory[] - /** - * Loaded actions in the categories. Should be loaded from - * `useLoadedActionsAndCategories`. - */ - loadedActions: LoadedActions /** * The react-hook-form field name that stores the action data. */ @@ -51,14 +42,20 @@ export type ActionLibraryProps = { defaultOpen?: boolean } +const ACTION_SEARCH_FILTERABLE_KEYS: Fuse.FuseOptionKey[] = [ + 'key', + 'metadata.label', + 'metadata.description', + 'metadata.keywords', +] + export const ActionLibrary = ({ - categories, - loadedActions, actionDataFieldName, onSelect, defaultOpen = true, }: ActionLibraryProps) => { const { t } = useTranslation() + const { actions, actionMap, categories } = useActionsContext() const { control, watch } = useFormContext<{ actionData: ActionKeyAndData[] @@ -69,28 +66,40 @@ export const ActionLibrary = ({ }) const actionData = watch(actionDataFieldName as 'actionData') || [] + const [, setSelectingActionKey] = useState() const onSelectRef = useUpdatingRef(onSelect) const onSelectAction = useCallback( - (action: Action) => { - const loadedAction = loadedActions[action.key] - if (!loadedAction) { - return + async (action: Action) => { + // Trigger state update so that UI updates based on action status. + setSelectingActionKey(action.key) + try { + onSelectRef.current?.(action) + + if (!action.ready) { + await action.init() + } + + addAction({ + // See `ActionKeyAndData` comment in `packages/types/actions.ts` for + // an explanation of why we need to append with a unique ID. + _id: uuidv4(), + actionKey: action.key, + // Clone to prevent the form from mutating the original object. + data: cloneDeep(action.defaults), + }) + } catch (err) { + console.error(err) + toast.error( + processError(err, { + forceCapture: false, + }) + ) + } finally { + // Trigger state update so that UI updates based on action status. + setSelectingActionKey(undefined) } - - onSelectRef.current?.(action) - - addAction({ - // See `ActionKeyAndData` comment in - // `packages/types/actions.ts` for an explanation of why we need to - // append with a unique ID. - _id: uuidv4(), - actionKey: action.key, - // Clone to prevent the form from mutating the original - // object. - data: cloneDeep(loadedAction.defaults ?? {}), - }) }, - [addAction, loadedActions, onSelectRef] + [addAction, onSelectRef] ) const [_categoryKeySelected, setCategoryKeySelected] = useState< @@ -109,17 +118,13 @@ export const ActionLibrary = ({ const itemsListRef = useRef(null) const searchBarRef = useRef(null) - const allActions = useMemo( - () => Object.values(loadedActions).map(({ action }) => action), - [loadedActions] - ) const { searchBarProps, filteredData: filteredActions, filter, setFilter, } = useSearchFilter({ - data: allActions, + data: actions, filterableKeys: ACTION_SEARCH_FILTERABLE_KEYS, // If filter is updated, unselect category and select first item. onFilterChange: () => { @@ -139,26 +144,29 @@ export const ActionLibrary = ({ const filterVisibleActions = (action: Action) => // Never show programmatic actions. - !action.programmaticOnly && + !action.metadata.programmaticOnly && // Never show actions that should be hidden from the picker. - !action.hideFromPicker && + !action.metadata.hideFromPicker && // Show if reusable or not already used. - (!action.notReusable || !actionData.some((a) => a.actionKey === action.key)) + (!action.metadata.notReusable || + !actionData.some((a) => a.actionKey === action.key)) const showingActions = ( categoryKeySelected - ? (selectedCategory || categories[0]).actions.filter(filterVisibleActions) + ? (selectedCategory || categories[0]).actionKeys + .flatMap((key) => actionMap[key] || []) + .filter(filterVisibleActions) : filteredActions .map(({ item }) => item) .filter(filterVisibleActions) .slice(0, 10) ).sort((a, b) => - a.order !== undefined && b.order !== undefined - ? a.order - b.order + a.metadata.listOrder !== undefined && b.metadata.listOrder !== undefined + ? a.metadata.listOrder - b.metadata.listOrder : // Prioritize the action with an order set. - a.order + a.metadata.listOrder ? -1 - : b.order + : b.metadata.listOrder ? 1 : // Leave them sorted by the original order in the category definition. 0 @@ -247,22 +255,6 @@ export const ActionLibrary = ({ } }, [handleKeyPress, isActionLibraryActive]) - const loadedActionValues = Object.values(loadedActions) - const loadingActionKeys = loadedActionValues.flatMap( - ({ action: { key }, defaults }) => (!defaults ? key : []) - ) - const erroredActionKeys = loadedActionValues.reduce( - (acc, { action: { key }, defaults }) => ({ - ...acc, - ...(defaults && defaults instanceof Error - ? { - [key]: defaults, - } - : {}), - }), - {} as Partial> - ) - return ( -
+
{showingActions.length > 0 ? (
{showingActions.map((action, index) => ( - + selected={selectedIndex === index} + /> ))}
) : ( @@ -368,8 +329,59 @@ export const ActionLibrary = ({ ) } -const ACTION_SEARCH_FILTERABLE_KEYS: Fuse.FuseOptionKey[] = [ - 'label', - 'description', - 'keywords', -] +export type ActionLibraryRowProps = { + /** + * The action to display. + */ + action: Action + /** + * Whether or not the action is selected. + */ + selected: boolean + /** + * Callback when the action is clicked. + */ + onClick: () => void +} + +export const ActionLibraryRow = ({ + action, + selected, + onClick, +}: ActionLibraryRowProps) => { + // If action is not ready, initialize it, and re-render when the promise + // changes since this affects the action state (e.g. loading, error, etc.). + useLoadingPromise({ + promise: async () => action.init(), + deps: [action], + }) + + return ( + + ) +} diff --git a/packages/stateless/components/actions/ActionsEditor.tsx b/packages/stateless/components/actions/ActionsEditor.tsx index 38a7656a0..20cc02232 100644 --- a/packages/stateless/components/actions/ActionsEditor.tsx +++ b/packages/stateless/components/actions/ActionsEditor.tsx @@ -1,6 +1,4 @@ -import { Add, Remove } from '@mui/icons-material' import clsx from 'clsx' -import cloneDeep from 'lodash.clonedeep' import { ComponentType, Fragment, @@ -10,30 +8,20 @@ import { useState, } from 'react' import { FieldErrors, useFieldArray, useFormContext } from 'react-hook-form' +import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' import { v4 as uuidv4 } from 'uuid' import { SuspenseLoaderProps } from '@dao-dao/types' -import { - ActionAndData, - ActionCategoryWithLabel, - ActionKeyAndData, - ActionKeyAndDataNoId, - LoadedActions, -} from '@dao-dao/types/actions' +import { ActionKeyAndData, ActionKeyAndDataNoId } from '@dao-dao/types/actions' -import { useDaoInfoContextIfAvailable } from '../../contexts' -import { IconButton } from '../icon_buttons' -import { PAGINATION_MIN_PAGE, Pagination } from '../Pagination' -import { Tooltip } from '../tooltip' -import { ActionCard, ActionCardLoader } from './ActionCard' +import { useActionsContext, useDaoInfoContextIfAvailable } from '../../contexts' +import { Loader } from '../logo' +import { ActionCard } from './ActionCard' import { ActionLibrary } from './ActionLibrary' -import { ACTIONS_PER_PAGE } from './ActionsRenderer' // The props needed to render an action from a message. export type ActionsEditorProps = { - categories: ActionCategoryWithLabel[] - loadedActions: LoadedActions actionDataFieldName: string actionDataErrors: FieldErrors | undefined className?: string @@ -41,107 +29,145 @@ export type ActionsEditorProps = { SuspenseLoader: ComponentType } -type GroupedActionData = Omit & { - actionDefaults: any - all: { - _id: string - // Index of data in `actionData` list. - index: number - data: any - }[] -} - -// Groups actions together and renders editable cards. +// Renders editable cards. export const ActionsEditor = ({ - categories, - loadedActions, - actionDataFieldName, + actionDataFieldName: _actionDataFieldName, actionDataErrors, className, hideEmptyPlaceholder, SuspenseLoader, }: ActionsEditorProps) => { const { t } = useTranslation() - const { watch } = useFormContext<{ + const { control, watch, clearErrors } = useFormContext<{ actionData: ActionKeyAndData[] }>() + const { actionMap } = useActionsContext() const isDao = !!useDaoInfoContextIfAvailable() - // All categorized actions from the form. - const actionData = watch(actionDataFieldName as 'actionData') || [] + // Type assertion assumes the passed in field name is correct. + const actionDataFieldName = _actionDataFieldName as 'actionData' - // Group action data by adjacent action, preserving order. Adjacent data of - // the same action are combined into a group so they can be rendered together. - const groupedActionData = actionData.reduce( - (acc, { _id, actionKey, data }, index): GroupedActionData[] => { - const loadedAction = actionKey && loadedActions[actionKey] + // All actions from the form. + // eslint-disable-next-line react-hooks/exhaustive-deps + const actionData = watch(actionDataFieldName) || [] - // If no action, skip. This is likely due to a cached action in the saved - // form that no longer exists, or was used and is no longer usable (such - // as enabling multiple choice). If action key is defined but no action is - // found, same thing. - if (!loadedAction) { - return acc + const { append, insert, remove } = useFieldArray({ + name: actionDataFieldName, + control, + }) + const addAction = useCallback( + async (data: ActionKeyAndDataNoId, insertIndex?: number) => { + const action = actionMap[data.actionKey] + if (!action) { + toast.error(t('errors.actionNotFound', { key: data.actionKey })) + return } - // If most recent group is for the current action, add the current - // action's data to the most recent group. - const lastGroup = acc[acc.length - 1] - if (lastGroup?.action && lastGroup.action.key === actionKey) { - // Add data to group. - lastGroup.all.push({ - _id, - index, - data, - }) - } else { - // Or create new group if previously adjacent group is for a different - // action. - acc.push({ - action: loadedAction.action, - actionDefaults: loadedAction.defaults, - all: [ - { - _id, - index, - data, - }, - ], - }) + if (!action.ready) { + await action.init() } - return acc + const actionData: ActionKeyAndData = { + // See `ActionKeyAndData` comment in `packages/types/actions.ts` for an + // explanation of why we need to append with a unique ID. + _id: uuidv4(), + // Allow overriding ID if passed. + ...data, + } + + return insertIndex !== undefined + ? insert(insertIndex, actionData) + : append(actionData) }, - [] as GroupedActionData[] + [actionMap, append, insert, t] ) // Start with scroll to new actions disabled to prevent scrolling on initial // page load. Only enable once an action is selected from the library. const [scrollToNewActions, setScrollToNewActions] = useState(false) + // If action is idle, initialize it. This ensures that actions loaded from + // saved form state are automatically initialized. + useEffect(() => { + actionData + .flatMap(({ actionKey }) => actionMap[actionKey] || []) + .forEach((action) => { + if (action.status === 'idle') { + action.init() + } + }) + }, [actionData, actionMap]) + + // IDs already seen. This is used to prevent scrolling to the same action more + // than once. + const idsSeenRef = useRef>(new Set()) + return ( <> - {groupedActionData.length > 0 ? ( + {actionData.length > 0 ? (
- {groupedActionData.map((group, index) => ( -
- -
- ))} + {actionData.map(({ _id, actionKey, data }, index) => { + const action = actionMap[actionKey] + if (!action) { + return null + } + + // Clear all errors when the action is removed, in case any manual + // errors were not cleaned up. If manual errors persist, the form + // gets stuck. + const onRemove = () => { + clearErrors(`${actionDataFieldName}.${index}`) + remove(index) + } + + return ( +
+ +
{ + // Scroll into view when added. If not scrolling, still + // register we saw it so we don't scroll later. + if (node && _id && !idsSeenRef.current.has(_id)) { + idsSeenRef.current.add(_id) + + if (scrollToNewActions) { + node.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }) + } + } + }} + > + }> + + +
+
+
+ ) + })}
) : ( !hideEmptyPlaceholder && ( @@ -155,12 +181,10 @@ export const ActionsEditor = ({ { // Enable scrolling to new actions once an action is selected for the // first time. @@ -170,219 +194,3 @@ export const ActionsEditor = ({ ) } - -export type ActionEditorProps = GroupedActionData & { - actionDataFieldName: string - // The errors for all actions, pointed to by `actionsFieldName` above. - actionDataErrors: FieldErrors | undefined - - scrollToNewActions: boolean - SuspenseLoader: ComponentType -} - -// Renders a group of data that belong to the same action, or a category action -// picker if no action is selected. -export const ActionEditor = ({ - actionDataFieldName: _actionDataFieldName, - actionDataErrors, - - action, - actionDefaults, - all, - - scrollToNewActions, - SuspenseLoader, -}: ActionEditorProps) => { - const { t } = useTranslation() - const { control, watch, clearErrors } = useFormContext<{ - actionData: ActionKeyAndData[] - }>() - - // Type assertion assumes the passed in field name is correct. - const actionDataFieldName = _actionDataFieldName as 'actionData' - const { append, insert, remove } = useFieldArray({ - name: actionDataFieldName, - control, - }) - const addAction = useCallback( - (data: ActionKeyAndDataNoId, insertIndex?: number) => { - const actionData: ActionKeyAndData = { - // See `ActionKeyAndData` comment in - // `packages/types/actions.ts` for an explanation of why we need to - // append with a unique ID. - _id: uuidv4(), - // Allow overriding ID if passed. - ...data, - } - - return insertIndex !== undefined - ? insert(insertIndex, actionData) - : append(actionData) - }, - [append, insert] - ) - - // All categorized actions from the form. - const actionData = watch(actionDataFieldName as 'actionData') || [] - - const [page, setPage] = useState(PAGINATION_MIN_PAGE) - const lastPage = Math.ceil(all.length / ACTIONS_PER_PAGE) - // If the last page changes, reset the page to it. This ensures that if an - // action gets deleted and the page we're on is now invalid, we reset to the - // last page. And if an action gets created and we're not on the last page - // anymore, go to it. - useEffect(() => { - setPage(lastPage) - }, [lastPage]) - - // Clear all errors when the action is removed, in case any manual errors were - // not cleaned up. If manual errors persist, the form gets stuck. - const onRemove = () => { - // Clear all errors for this action. - all.forEach(({ index }) => clearErrors(`${actionDataFieldName}.${index}`)) - - // Remove all entries for this action. Remove the indices in reverse order - // to prevent the indices from changing. This assumes `all` is ordered by - // ascending index. - all.reverse().forEach(({ index }) => remove(index)) - } - - const lastIndex = Math.min(all.length, ACTIONS_PER_PAGE) - 1 - const allowAdding = !action.notReusable && !action.programmaticOnly - - // IDs already seen. This is used to prevent scrolling to the same action more - // than once. - const idsSeenRef = useRef>(new Set()) - - return ( - - {all - // Paginate. - .slice((page - 1) * ACTIONS_PER_PAGE, page * ACTIONS_PER_PAGE) - .map(({ _id, index, data }, rowIndex) => { - const removeAction = () => { - clearErrors(`${actionDataFieldName}.${index}`) - remove(index) - } - - return ( - -
{ - // Scroll new actions into view when added. If not scrolling, - // still register we saw these so we don't scroll later. - if (node && _id && !idsSeenRef.current.has(_id)) { - idsSeenRef.current.add(_id) - - if (scrollToNewActions) { - node.scrollIntoView({ - behavior: 'smooth', - block: 'center', - }) - } - } - }} - > -
- }> - - -
- - { - // Never show remove button for programmatic actions. Show - // remove button if action is resuable OR if there are more - // than one action. If there are more than one action, - // individual ones should be removable. But if there is only - // one, which is the intended state for an action configured - // as not reusable, don't show the remove button. - !action.programmaticOnly && - (!action.notReusable || all.length > 1) && ( - - - - ) - } -
- - {(rowIndex < lastIndex || allowAdding) && ( -
- )} -
- ) - })} - - {/* Don't show add button if action is not reusable or if programmatic. */} - {allowAdding && ( - - { - // Insert another entry for the same action with the default - // values after the last one in this group. - addAction( - { - actionKey: action.key, - data: cloneDeep(actionDefaults ?? {}), - }, - all[all.length - 1].index + 1 - ) - - // Go to the last page. - setPage(lastPage) - }} - size="sm" - variant="secondary" - /> - - )} - - {lastPage > PAGINATION_MIN_PAGE && ( -
- -
- )} -
- ) -} diff --git a/packages/stateless/components/actions/ActionsRenderer.tsx b/packages/stateless/components/actions/ActionsRenderer.tsx index 12fa4c98b..1d2bc786c 100644 --- a/packages/stateless/components/actions/ActionsRenderer.tsx +++ b/packages/stateless/components/actions/ActionsRenderer.tsx @@ -1,26 +1,36 @@ -import { Check, Link, WarningRounded } from '@mui/icons-material' -import { ComponentType, Fragment, useEffect, useMemo, useState } from 'react' +import { Check, Link } from '@mui/icons-material' +import { ComponentType, useEffect, useState } from 'react' import { FormProvider, useForm } from 'react-hook-form' -import { useTranslation } from 'react-i18next' -import { useDeepCompareMemoize } from 'use-deep-compare-effect' -import { SuspenseLoaderProps } from '@dao-dao/types' -import { Action, ActionAndData, ActionKeyAndData } from '@dao-dao/types/actions' +import { SuspenseLoaderProps, UnifiedCosmosMsg } from '@dao-dao/types' +import { + Action, + ActionAndData, + ActionKeyAndData, + IActionDecoder, +} from '@dao-dao/types/actions' +import { useActionMatcher } from '../../contexts' +import { useLoadingPromise, useUpdatingRef } from '../../hooks' +import { ActionMatcherProvider } from '../../providers' +import { ErrorPage } from '../error' import { IconButton } from '../icon_buttons' -import { PAGINATION_MIN_PAGE, Pagination } from '../Pagination' +import { Loader } from '../logo' import { ActionCard, ActionCardLoader } from './ActionCard' export const ACTIONS_PER_PAGE = 20 -// The props needed to render an action from a message. -export interface ActionsRendererProps { - actionData: ActionAndData[] +export type ActionsRendererProps = { + /** + * Array of actions with data or action decoders from the matcher. + */ + actionData: (ActionAndData | IActionDecoder)[] hideCopyLink?: boolean onCopyLink?: () => void - // If undefined, will not show warning to view all pages. This is likely only - // defined when the user can vote. - setSeenAllActionPages?: () => void + /** + * Callback when all actions and data are loaded. + */ + onLoad?: (data: ActionKeyAndData[]) => void SuspenseLoader: ComponentType } @@ -29,55 +39,9 @@ export const ActionsRenderer = ({ actionData, hideCopyLink, onCopyLink, - setSeenAllActionPages, + onLoad, SuspenseLoader, }: ActionsRendererProps) => { - const actionKeysWithData = useMemo( - () => - actionData.map( - ({ action: { key: actionKey }, data }, index): ActionKeyAndData => ({ - _id: index.toString(), - actionKey, - data, - }) - ), - // eslint-disable-next-line react-hooks/exhaustive-deps - useDeepCompareMemoize([actionData]) - ) - - // Group action data by adjacent action, preserving order. - const groupedActionData = useMemo( - () => - actionData.reduce((acc, { action, data }, index) => { - // If most recent action is the same as the current action, add the - // current action's data to the most recent action's data. - const lastAction = acc[acc.length - 1] - if (lastAction && lastAction.action.key === action.key) { - lastAction.all.push({ - data, - // Index in the original array. - index, - }) - } else { - // Otherwise, add a new action to the list. - acc.push({ - action, - all: [ - { - data, - // Index in the original array. - index, - }, - ], - }) - } - - return acc - }, [] as Omit[]), - // eslint-disable-next-line react-hooks/exhaustive-deps - useDeepCompareMemoize([actionData]) - ) - const [copied, setCopied] = useState() // Unset copied after 2 seconds. useEffect(() => { @@ -86,43 +50,49 @@ export const ActionsRenderer = ({ return () => clearTimeout(timeout) }, [copied]) - // Store for each action group whether the user has seen all pages. - const [seenAllPagesForAction, setSeenAllPagesForAction] = useState(() => - groupedActionData.reduce( - (acc, { all }, index) => ({ - ...acc, - [index]: all.length <= ACTIONS_PER_PAGE, - }), - {} as Record - ) - ) - // Check that every action has seen all pages, and if so, call the - // `setSeenAllActionPages` callback. - const [markedSeen, setMarkedSeen] = useState(false) - useEffect(() => { - if (markedSeen) { - return - } + const loadingAllActionsWithData = useLoadingPromise({ + promise: () => + Promise.all( + actionData.map(async (data, index): Promise => { + if ('decode' in data) { + return { + _id: index.toString(), + actionKey: data.action.key, + data: await data.decode().catch(() => {}), + } + } else { + return { + _id: index.toString(), + actionKey: data.action.key, + data: data.data, + } + } + }) + ), + deps: [actionData], + }) + // Call onLoad callback once all actions and data are loaded. + const onLoadRef = useUpdatingRef(onLoad) + const onLoadDefined = !!onLoad + useEffect(() => { if ( - setSeenAllActionPages && - [...Array(groupedActionData.length)].every( - (_, index) => seenAllPagesForAction[index] - ) + !loadingAllActionsWithData.loading && + !loadingAllActionsWithData.errored && + !loadingAllActionsWithData.updating ) { - setSeenAllActionPages() - setMarkedSeen(true) + onLoadRef.current?.(loadingAllActionsWithData.data) } }, [ - groupedActionData.length, - markedSeen, - seenAllPagesForAction, - setSeenAllActionPages, + loadingAllActionsWithData, + onLoadRef, + // Make sure to re-run the effect if the callback becomes defined. + onLoadDefined, ]) return (
- {groupedActionData.map((props, index) => ( + {actionData.map((data, index) => (
- setSeenAllPagesForAction((prev) => - // Don't update if already true. - prev[index] - ? prev - : { - ...prev, - [index]: true, - } - )) + action={data.action} + allActionsWithData={ + loadingAllActionsWithData.loading || + loadingAllActionsWithData.errored + ? [] + : loadingAllActionsWithData.data } + {...('decode' in data + ? { + decoder: data, + } + : { + data: data.data, + })} /> {!hideCopyLink && ( @@ -177,122 +146,131 @@ export const ActionsRenderer = ({ ) } -export type ActionRendererProps = { - action: Action - all: { - // Index of data in `allActionsWithData` list. - index: number - data: any - }[] +export type ActionRendererProps< + Data extends Record = Record +> = ({ + action: Action +} & ( + | { + data: Data + decoder?: never + } + | { + decoder: IActionDecoder + data?: never + } +)) & + Omit, 'data'> + +// Renders an action. +export const ActionRenderer = < + Data extends Record = Record +>({ + action, + ...props +}: ActionRendererProps) => { + const data = useLoadingPromise({ + promise: async () => (props.decoder ? props.decoder.decode() : props.data), + deps: [props.data, props.decoder], + }) + + return data.loading ? ( + + + + ) : data.errored ? ( + + + + ) : ( + {...props} action={action} data={data.data} /> + ) +} + +type InnerActionRendererProps< + Data extends Record = Record +> = { + action: Action + data: Data allActionsWithData: ActionKeyAndData[] - // If undefined, will not show warning to view all pages. This is likely only - // defined when the user can vote. - setSeenAllPages?: () => void SuspenseLoader: ComponentType } -// Renders a group of data that belong to the same action. -export const ActionRenderer = ({ +const InnerActionRenderer = < + Data extends Record = Record +>({ action, - all, + data, allActionsWithData, - setSeenAllPages, SuspenseLoader, -}: ActionRendererProps) => { - const { t } = useTranslation() +}: InnerActionRendererProps) => { const form = useForm({ defaultValues: { - data: all.map(({ data }) => data), + data: data as any, }, }) - const [page, setPage] = useState(PAGINATION_MIN_PAGE) - const minIndex = (page - 1) * ACTIONS_PER_PAGE - const maxIndex = page * ACTIONS_PER_PAGE - const maxPage = Math.ceil(all.length / ACTIONS_PER_PAGE) - - // Store pages visited so we can check if we've seen all pages. Initialize to - // the first page. - const [pagesVisited, setPagesVisited] = useState(() => new Set([page])) - useEffect(() => { - setPagesVisited((prev) => { - const next = new Set(prev) - next.add(page) - return next - }) - }, [page]) - - const [markedSeen, setMarkedSeen] = useState(false) - useEffect(() => { - if (markedSeen) { - return - } - - // If all pages have been visited, mark as seen. - if (setSeenAllPages && pagesVisited.size === maxPage) { - setSeenAllPages() - setMarkedSeen(true) - } - }, [markedSeen, maxPage, page, pagesVisited.size, setSeenAllPages]) - return ( - - {all.map( - ({ index, data }, dataIndex) => - // Paginate manually instead of slicing the array so that the - // `dataIndex` matches the index in the `data` array of the form. - dataIndex >= minIndex && - dataIndex < maxIndex && ( - -
- }> - - -
- - {dataIndex < all.length - 1 && ( -
- )} -
- ) - )} + + }> + + + +
+ ) +} - {maxPage > PAGINATION_MIN_PAGE && ( -
- {setSeenAllPages && ( -
- +export type ActionsMatchAndRenderProps = Omit< + ActionsRendererProps, + 'actionData' +> & { + /** + * The messages to match. + */ + messages: UnifiedCosmosMsg[] + /** + * Callback when all actions and data are loaded. + */ + onLoad?: (data: ActionKeyAndData[]) => void +} -

- {t('info.actionPageWarning', { - actions: all.length, - pages: maxPage, - })} -

-
- )} +/** + * An ActionsRenderer wrapper that renders the ActionsRenderer component with + * matched actions for the provided messages, or loading/error appropriately. + */ +export const ActionsMatchAndRender = ({ + messages, + ...props +}: ActionsMatchAndRenderProps) => ( + + + +) - -
- )} - - +const InnerActionsMatchAndRender = ( + props: Omit +) => { + const matcher = useActionMatcher() + return ( + <> + {matcher.errored ? ( + + ) : matcher.ready ? ( + + ) : ( +
+ + + +
+ )} + ) } diff --git a/packages/stateless/components/actions/NativeCoinSelector.tsx b/packages/stateless/components/actions/NativeCoinSelector.tsx index cc76f8f82..581c8844a 100644 --- a/packages/stateless/components/actions/NativeCoinSelector.tsx +++ b/packages/stateless/components/actions/NativeCoinSelector.tsx @@ -122,7 +122,7 @@ export const NativeCoinSelector = ({ : !selectedToken ? t('error.unknownDenom', { denom: watchDenom }) : watchAmount > balance - ? t('error.spendActionInsufficientWarning', { + ? t('error.insufficientFundsWarning', { amount: balance.toLocaleString(undefined, { maximumFractionDigits: decimals, }), @@ -210,8 +210,8 @@ export const NativeCoinSelector = ({ )}
- - + +
) } diff --git a/packages/stateless/components/actions/NestedActionsEditor.tsx b/packages/stateless/components/actions/NestedActionsEditor.tsx index d861dd2a0..b09dd557e 100644 --- a/packages/stateless/components/actions/NestedActionsEditor.tsx +++ b/packages/stateless/components/actions/NestedActionsEditor.tsx @@ -1,24 +1,22 @@ -import { ComponentType } from 'react' +import { ComponentType, useEffect } from 'react' import { useFormContext } from 'react-hook-form' -import useDeepCompareEffect from 'use-deep-compare-effect' import { - ActionCategoryWithLabel, ActionComponent, - LoadedActions, + ActionEncodeContext, NestedActionsEditorFormData, SuspenseLoaderProps, - UnifiedCosmosMsg, } from '@dao-dao/types' -import { convertActionsToMessages } from '@dao-dao/utils' +import { encodeActions } from '@dao-dao/utils' -import { Loader } from '../logo' +import { useActionsContext } from '../../contexts' +import { useLoadingPromise } from '../../hooks' +import { InputErrorMessage } from '../inputs' import { ActionsEditor } from './ActionsEditor' export type NestedActionsEditorOptions = { - categories: ActionCategoryWithLabel[] - loadedActions: LoadedActions SuspenseLoader: ComponentType + encodeContext: ActionEncodeContext } export const NestedActionsEditor: ActionComponent< @@ -26,8 +24,9 @@ export const NestedActionsEditor: ActionComponent< > = ({ fieldNamePrefix, errors, - options: { categories, loadedActions, SuspenseLoader }, + options: { SuspenseLoader, encodeContext }, }) => { + const { actionMap } = useActionsContext() const { watch, setValue, clearErrors, setError } = useFormContext() @@ -35,29 +34,35 @@ export const NestedActionsEditor: ActionComponent< watch((fieldNamePrefix + '_actionData') as '_actionData') || [] // Update action msgs from actions form data. - let msgs: UnifiedCosmosMsg[] = [] - try { - msgs = convertActionsToMessages(loadedActions, actionData) + const msgs = useLoadingPromise({ + promise: actionData.length + ? () => + encodeActions({ + actionMap, + encodeContext, + data: actionData, + }) + : () => Promise.resolve([]), + deps: [actionMap, actionData], + }) - if (errors?.msgs) { - clearErrors((fieldNamePrefix + 'msgs') as 'msgs') + useEffect(() => { + if (msgs.loading) { + return } - } catch (err) { - console.error(err) - if (!errors?.msgs) { + if (msgs.errored) { + console.error(msgs.error) setError((fieldNamePrefix + 'msgs') as 'msgs', { type: 'manual', - message: err instanceof Error ? err.message : `${err}`, + message: msgs.error.message, }) + return } - } - useDeepCompareEffect(() => { - if (msgs) { - setValue((fieldNamePrefix + 'msgs') as 'msgs', msgs) - } - }, [msgs]) + clearErrors((fieldNamePrefix + 'msgs') as 'msgs') + setValue((fieldNamePrefix + 'msgs') as 'msgs', msgs.data) + }, [clearErrors, fieldNamePrefix, msgs, setError, setValue]) return (
@@ -65,12 +70,10 @@ export const NestedActionsEditor: ActionComponent< SuspenseLoader={SuspenseLoader} actionDataErrors={errors?._actionData} actionDataFieldName={fieldNamePrefix + '_actionData'} - categories={categories} hideEmptyPlaceholder - loadedActions={loadedActions} /> - {categories.length === 0 && } +
) } diff --git a/packages/stateless/components/actions/NestedActionsRenderer.tsx b/packages/stateless/components/actions/NestedActionsRenderer.tsx index 9379168c5..71e8f82c6 100644 --- a/packages/stateless/components/actions/NestedActionsRenderer.tsx +++ b/packages/stateless/components/actions/NestedActionsRenderer.tsx @@ -1,66 +1,30 @@ -import { ComponentType, useMemo } from 'react' +import { ComponentType } from 'react' import { useFormContext } from 'react-hook-form' -import { - Action, - ActionAndData, - SuspenseLoaderProps, - UnifiedCosmosMsg, -} from '@dao-dao/types' -import { decodeMessages } from '@dao-dao/utils' +import { SuspenseLoaderProps, UnifiedCosmosMsg } from '@dao-dao/types' -import { ActionCardLoader } from './ActionCard' -import { ActionsRenderer } from './ActionsRenderer' +import { ActionsMatchAndRender } from './ActionsRenderer' export type NestedActionsRendererProps = { // Must point to a `msgs` field in the current form context. msgsFieldName: string - actionsForMatching: Action[] SuspenseLoader: ComponentType } export const NestedActionsRenderer = ({ msgsFieldName, - actionsForMatching, SuspenseLoader, }: NestedActionsRendererProps) => { const { watch } = useFormContext<{ msgs: UnifiedCosmosMsg[] }>() - const msgs = watch(msgsFieldName as 'msgs') - - const decodedMessages = useMemo(() => decodeMessages(msgs), [msgs]) - - // Call relevant action hooks in the same order every time. - const actionData = decodedMessages - .map((message) => { - const actionMatch = actionsForMatching - .map((action) => ({ - action, - ...action.useDecodedCosmosMsg(message), - })) - .find(({ match }) => match) - - return ( - actionMatch && { - action: actionMatch.action, - data: actionMatch.data, - } - ) - }) - .filter(Boolean) as ActionAndData[] + const messages = watch(msgsFieldName as 'msgs') return ( - <> - {actionsForMatching.length === 0 ? ( - - ) : ( - - )} - + ) } diff --git a/packages/stateless/components/actions/RawActionsRenderer.tsx b/packages/stateless/components/actions/RawActionsRenderer.tsx index b16bc56d5..89f7a1574 100644 --- a/packages/stateless/components/actions/RawActionsRenderer.tsx +++ b/packages/stateless/components/actions/RawActionsRenderer.tsx @@ -1,38 +1,84 @@ import { useMemo } from 'react' -import { ActionKeyAndData, LoadedActions } from '@dao-dao/types' import { - convertActionsToMessages, - decodeMessages, - decodeRawDataForDisplay, -} from '@dao-dao/utils' + ActionEncodeContext, + ActionKeyAndDataNoId, + UnifiedCosmosMsg, +} from '@dao-dao/types' +import { decodeMessages, decodeRawDataForDisplay } from '@dao-dao/utils' +import { useActionsEncoder } from '../../contexts/ActionsEncoder' +import { ActionsEncoderProvider } from '../../providers/ActionsEncoder' import { CosmosMessageDisplay } from '../CosmosMessageDisplay' +import { ErrorPage } from '../error' -export type RawActionsRendererProps = { - // This likely comes from a form field array that holds the action data. - actionData: ActionKeyAndData[] - // This comes from the `useLoadedActionsAndCategories` hook. - loadedActions: LoadedActions -} +export type RawActionsRendererProps = + | { + /** + * This likely comes from a form field array that holds the action data. + */ + actionKeysAndData: ActionKeyAndDataNoId[] + /** + * Encode context. + */ + encodeContext: ActionEncodeContext + /** + * Existing messages to display. + */ + messages?: never + } + | { + /** + * Existing messages to display. + */ + messages: UnifiedCosmosMsg[] + /** + * This likely comes from a form field array that holds the action data. + */ + actionKeysAndData?: never + /** + * Encode context. + */ + encodeContext?: never + } + +export const RawActionsRenderer = (props: RawActionsRendererProps) => + props.actionKeysAndData ? ( + + + + ) : ( + + ) -export const RawActionsRenderer = ({ - actionData, - loadedActions, -}: RawActionsRendererProps) => { - const rawDecodedMessages = useMemo( - () => - JSON.stringify( - decodeMessages( - convertActionsToMessages(loadedActions, actionData, { - throwErrors: false, - }) - ).map(decodeRawDataForDisplay), - null, - 2 - ), - [loadedActions, actionData] +const RawActionsRendererEncoder = () => { + const encoder = useActionsEncoder() + + return ( + <> + {encoder.errored ? ( + + ) : encoder.ready ? ( + + ) : ( + + )} + ) +} + +export const RawActionsRendererMessages = ({ + messages, +}: { + messages: any[] +}) => { + const value = useMemo(() => { + const decoded = decodeMessages(messages).map(decodeRawDataForDisplay) + return JSON.stringify(decoded.length === 1 ? decoded[0] : decoded, null, 2) + }, [messages]) - return + return } diff --git a/packages/stateless/components/chain/ChainProvider.tsx b/packages/stateless/components/chain/ChainProvider.tsx index 220d4a851..0abfd6981 100644 --- a/packages/stateless/components/chain/ChainProvider.tsx +++ b/packages/stateless/components/chain/ChainProvider.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react' +import { ReactNode, useMemo } from 'react' import { getChainForChainId, @@ -14,16 +14,19 @@ export type ChainProviderProps = { children: ReactNode | ReactNode[] } -export const ChainProvider = ({ chainId, children }: ChainProviderProps) => ( - { + const context = useMemo( + () => ({ chainId, chain: getChainForChainId(chainId), nativeToken: maybeGetNativeTokenForChainId(chainId), base: getConfiguredChainConfig(chainId), config: getSupportedChainConfig(chainId), - }} - > - {children} - -) + }), + [chainId] + ) + + return ( + {children} + ) +} diff --git a/packages/stateless/components/dao/tabs/GovProposalsTab.tsx b/packages/stateless/components/dao/tabs/GovProposalsTab.tsx index e9cb8af9d..2f44255f3 100644 --- a/packages/stateless/components/dao/tabs/GovProposalsTab.tsx +++ b/packages/stateless/components/dao/tabs/GovProposalsTab.tsx @@ -10,7 +10,7 @@ import { usePlatform } from '../../../hooks' import { Tooltip } from '../../tooltip/Tooltip' export interface GovProposalsTabProps { - ProposalList: ComponentType + ProposalList: ComponentType<{ className: string }> ButtonLink: ComponentType } @@ -65,7 +65,7 @@ export const GovProposalsTab = ({
- + ) } diff --git a/packages/stateless/components/dao/tabs/ProposalsTab.stories.tsx b/packages/stateless/components/dao/tabs/ProposalsTab.stories.tsx index 41228c165..ab3d64c71 100644 --- a/packages/stateless/components/dao/tabs/ProposalsTab.stories.tsx +++ b/packages/stateless/components/dao/tabs/ProposalsTab.stories.tsx @@ -1,14 +1,11 @@ import { ComponentMeta, ComponentStory } from '@storybook/react' import { DaoPageWrapperDecorator } from '@dao-dao/storybook/decorators' +import { ProposalListProps } from '@dao-dao/types' import { useDaoInfoContext } from '../../../contexts/Dao' import { ButtonLink } from '../../buttons' -import { - ProposalLineProps, - ProposalList, - ProposalListProps, -} from '../../proposal' +import { ProposalLineProps, ProposalList } from '../../proposal' import * as ProposalListStories from '../../proposal/ProposalList.stories' import { ProposalsTab } from './ProposalsTab' diff --git a/packages/stateless/components/dao/tabs/ProposalsTab.tsx b/packages/stateless/components/dao/tabs/ProposalsTab.tsx index ccc38d4dc..bfa69a40d 100644 --- a/packages/stateless/components/dao/tabs/ProposalsTab.tsx +++ b/packages/stateless/components/dao/tabs/ProposalsTab.tsx @@ -73,7 +73,7 @@ export const ProposalsTab = ({
- + ) } diff --git a/packages/stateless/components/dao/tabs/TreasuryTab.tsx b/packages/stateless/components/dao/tabs/TreasuryTab.tsx index 40ab4e919..41f48fbe6 100644 --- a/packages/stateless/components/dao/tabs/TreasuryTab.tsx +++ b/packages/stateless/components/dao/tabs/TreasuryTab.tsx @@ -350,7 +350,7 @@ export const TreasuryTab = ({ & { labelI18nKey: string emoji: string } -export const EmojiWrapper = ({ labelI18nKey, emoji }: EmojiWrapperProps) => { +export type EmojiProps = Omit + +export const EmojiWrapper = ({ + labelI18nKey, + emoji, + ...props +}: EmojiWrapperProps) => { const { t } = useTranslation() - return + return } -export const GasEmoji = () => ( - +export const GasEmoji = (props: EmojiProps) => ( + +) + +export const KeyEmoji = (props: EmojiProps) => ( + ) -export const KeyEmoji = () => ( - +export const LockWithKeyEmoji = (props: EmojiProps) => ( + ) -export const LockWithKeyEmoji = () => ( - +export const PickEmoji = (props: EmojiProps) => ( + ) -export const PickEmoji = () => ( - +export const PencilEmoji = (props: EmojiProps) => ( + ) -export const PencilEmoji = () => ( - +export const UnlockEmoji = (props: EmojiProps) => ( + ) -export const UnlockEmoji = () => ( - +export const CameraWithFlashEmoji = (props: EmojiProps) => ( + ) -export const CameraWithFlashEmoji = () => ( - +export const BoxEmoji = (props: EmojiProps) => ( + ) -export const BoxEmoji = () => ( - +export const MoneyEmoji = (props: EmojiProps) => ( + ) -export const MoneyEmoji = () => ( - +export const MoneyBagEmoji = (props: EmojiProps) => ( + ) -export const MoneyBagEmoji = () => ( - +export const MoneyWingsEmoji = (props: EmojiProps) => ( + ) -export const MoneyWingsEmoji = () => ( - +export const BankEmoji = (props: EmojiProps) => ( + ) -export const BankEmoji = () => ( - +export const DepositEmoji = (props: EmojiProps) => ( + ) -export const DepositEmoji = () => ( - +export const TokenEmoji = (props: EmojiProps) => ( + ) -export const TokenEmoji = () => ( - +export const ImageEmoji = (props: EmojiProps) => ( + ) -export const ImageEmoji = () => ( - +export const CameraEmoji = (props: EmojiProps) => ( + ) -export const CameraEmoji = () => ( - +export const ArtistPaletteEmoji = (props: EmojiProps) => ( + ) -export const ArtistPaletteEmoji = () => ( - +export const RobotEmoji = (props: EmojiProps) => ( + ) -export const RobotEmoji = () => ( - +export const SwordsEmoji = (props: EmojiProps) => ( + ) -export const SwordsEmoji = () => ( - +export const BabyEmoji = (props: EmojiProps) => ( + ) -export const BabyEmoji = () => ( - +export const BabyAngelEmoji = (props: EmojiProps) => ( + ) -export const BabyAngelEmoji = () => ( - +export const WhaleEmoji = (props: EmojiProps) => ( + ) -export const WhaleEmoji = () => ( - +export const XEmoji = (props: EmojiProps) => ( + ) -export const XEmoji = () => +export const MushroomEmoji = (props: EmojiProps) => ( + +) -export const MushroomEmoji = () => ( - +export const InfoEmoji = (props: EmojiProps) => ( + ) -export const InfoEmoji = () => ( - +export const FamilyEmoji = (props: EmojiProps) => ( + ) -export const FamilyEmoji = () => ( - +export const GearEmoji = (props: EmojiProps) => ( + ) -export const GearEmoji = () => ( - +export const ChartEmoji = (props: EmojiProps) => ( + ) -export const ChartEmoji = () => ( - +export const PeopleEmoji = (props: EmojiProps) => ( + ) -export const PeopleEmoji = () => ( - +export const ClockEmoji = (props: EmojiProps) => ( + ) -export const ClockEmoji = () => ( - +export const RecycleEmoji = (props: EmojiProps) => ( + ) -export const RecycleEmoji = () => ( - +export const MegaphoneEmoji = (props: EmojiProps) => ( + ) -export const MegaphoneEmoji = () => ( - +export const BallotDepositEmoji = (props: EmojiProps) => ( + ) -export const BallotDepositEmoji = () => ( - +export const RaisedHandEmoji = (props: EmojiProps) => ( + ) -export const RaisedHandEmoji = () => ( - +export const HourglassEmoji = (props: EmojiProps) => ( + ) -export const HourglassEmoji = () => ( - +export const HerbEmoji = (props: EmojiProps) => ( + ) -export const HerbEmoji = () => ( - +export const DaoEmoji = (props: EmojiProps) => ( + ) -export const DaoEmoji = () => ( - +export const HandshakeEmoji = (props: EmojiProps) => ( + ) -export const HandshakeEmoji = () => ( - +export const BrokenHeartEmoji = (props: EmojiProps) => ( + ) -export const BrokenHeartEmoji = () => ( - +export const WrenchEmoji = (props: EmojiProps) => ( + ) -export const WrenchEmoji = () => ( - +export const FireEmoji = (props: EmojiProps) => ( + ) -export const FireEmoji = () => ( - +export const UnicornEmoji = (props: EmojiProps) => ( + ) -export const UnicornEmoji = () => ( - +export const LockWithPenEmoji = (props: EmojiProps) => ( + ) -export const LockWithPenEmoji = () => ( - +export const BeeEmoji = (props: EmojiProps) => ( + ) -export const BeeEmoji = () => ( - +export const SuitAndTieEmoji = (props: EmojiProps) => ( + ) -export const SuitAndTieEmoji = () => ( - +export const CycleEmoji = (props: EmojiProps) => ( + ) -export const CycleEmoji = () => ( - +export const JoystickEmoji = (props: EmojiProps) => ( + ) -export const JoystickEmoji = () => ( - +export const NumbersEmoji = (props: EmojiProps) => ( + ) -export const NumbersEmoji = () => ( - +export const HammerAndWrenchEmoji = (props: EmojiProps) => ( + ) -export const HammerAndWrenchEmoji = () => ( - +export const FileFolderEmoji = (props: EmojiProps) => ( + ) -export const FileFolderEmoji = () => ( - +export const MemoEmoji = (props: EmojiProps) => ( + ) -export const MemoEmoji = () => ( - +export const TrashEmoji = (props: EmojiProps) => ( + ) -export const TrashEmoji = () => ( - +export const ChainEmoji = (props: EmojiProps) => ( + ) -export const ChainEmoji = () => ( - +export const DottedLineFaceEmoji = (props: EmojiProps) => ( + ) -export const TelescopeEmoji = () => ( - +export const TelescopeEmoji = (props: EmojiProps) => ( + ) -export const CurvedDownArrowEmoji = () => ( - +export const CurvedDownArrowEmoji = (props: EmojiProps) => ( + ) -export const DownArrowEmoji = () => ( - +export const DownArrowEmoji = (props: EmojiProps) => ( + ) -export const FilmSlateEmoji = () => ( - +export const FilmSlateEmoji = (props: EmojiProps) => ( + ) -export const PrinterEmoji = () => ( - +export const PrinterEmoji = (props: EmojiProps) => ( + ) -export const BalanceEmoji = () => ( - +export const BalanceEmoji = (props: EmojiProps) => ( + ) -export const RocketShipEmoji = () => ( - +export const RocketShipEmoji = (props: EmojiProps) => ( + ) -export const AtomEmoji = () => ( - +export const AtomEmoji = (props: EmojiProps) => ( + ) -export const PersonRaisingHandEmoji = () => ( - +export const PersonRaisingHandEmoji = (props: EmojiProps) => ( + ) -export const ControlKnobsEmoji = () => ( - +export const ControlKnobsEmoji = (props: EmojiProps) => ( + ) -export const ThumbDownEmoji = () => ( - +export const ThumbDownEmoji = (props: EmojiProps) => ( + ) -export const ComputerDiskEmoji = () => ( - +export const ComputerDiskEmoji = (props: EmojiProps) => ( + ) -export const PlayPauseEmoji = () => ( - +export const PlayPauseEmoji = (props: EmojiProps) => ( + ) -export const PufferfishEmoji = () => ( - +export const PufferfishEmoji = (props: EmojiProps) => ( + ) -export const CheckEmoji = () => ( - +export const CheckEmoji = (props: EmojiProps) => ( + ) diff --git a/packages/stateless/components/error/ErrorPage404.tsx b/packages/stateless/components/error/ErrorPage404.tsx index 4743669df..59c19a3a4 100644 --- a/packages/stateless/components/error/ErrorPage404.tsx +++ b/packages/stateless/components/error/ErrorPage404.tsx @@ -1,7 +1,5 @@ import { useTranslation } from 'react-i18next' -import { SuspenseLoader } from '@dao-dao/stateful' - import { useDaoNavHelpers } from '../../hooks' import { ButtonLink } from '../buttons' import { ErrorPage } from './ErrorPage' @@ -20,15 +18,13 @@ export const ErrorPage404 = () => { : null return ( - - - - {t('button.returnHome')} - - - + + + {t('button.returnHome')} + + ) } diff --git a/packages/stateless/components/error/ErrorPage500.tsx b/packages/stateless/components/error/ErrorPage500.tsx index 156dd8a27..9bf08e958 100644 --- a/packages/stateless/components/error/ErrorPage500.tsx +++ b/packages/stateless/components/error/ErrorPage500.tsx @@ -1,9 +1,9 @@ import { ComponentType, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { ButtonLink } from '@dao-dao/stateful' import { PageHeaderProps } from '@dao-dao/types' +import { ButtonLink } from '../buttons' import { ErrorPage } from './ErrorPage' export interface ErrorPage500Props { diff --git a/packages/stateless/components/inputs/DaoSupportedChainPickerInput.tsx b/packages/stateless/components/inputs/DaoSupportedChainPickerInput.tsx index c31ff5c94..2aa805daa 100644 --- a/packages/stateless/components/inputs/DaoSupportedChainPickerInput.tsx +++ b/packages/stateless/components/inputs/DaoSupportedChainPickerInput.tsx @@ -2,7 +2,8 @@ import clsx from 'clsx' import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' -import { ChainPickerPopupProps } from '@dao-dao/types' +import { AccountType, ChainPickerPopupProps } from '@dao-dao/types' +import { getIbcTransferChainIdsForChain } from '@dao-dao/utils' import { useChainContext, useDaoInfoContextIfAvailable } from '../../contexts' import { ChainPickerPopup } from '../popup' @@ -27,10 +28,16 @@ export type DaoSupportedChainPickerInputProps = { excludeChainIds?: string[] /** * Whether to include only the chains the DAO has an account on (its native - * chain or one of its polytone chains). + * chain or one of its cross-chain accounts). * Defaults to false. */ onlyDaoChainIds?: boolean + /** + * Which potential account types of chains for this DAO to include. + * + * Defaults to Polytone only. + */ + accountTypes?: (AccountType.Polytone | AccountType.Ica)[] /** * Whether to hide the form label. */ @@ -52,6 +59,7 @@ export const DaoSupportedChainPickerInput = ({ includeChainIds: _includeChainIds, excludeChainIds, onlyDaoChainIds = false, + accountTypes = [AccountType.Polytone], hideFormLabel = false, className, labelMode = 'chain', @@ -67,7 +75,12 @@ export const DaoSupportedChainPickerInput = ({ const includeChainIds = onlyDaoChainIds && daoInfo - ? [daoInfo.chainId, ...Object.keys(daoInfo.polytoneProxies)] + ? [ + daoInfo.chainId, + ...daoInfo.accounts.flatMap(({ type, chainId }) => + accountTypes.includes(type as any) ? chainId : [] + ), + ] : _includeChainIds // Only works on supported chains, so don't render if no supported chain @@ -79,7 +92,13 @@ export const DaoSupportedChainPickerInput = ({ const chainIds = [ chainId, // Other chains with Polytone connections to this one. - ...Object.keys(config.polytone || {}), + ...(accountTypes.includes(AccountType.Polytone) + ? Object.keys(config.polytone || {}) + : []), + // Other chains with IBC transfer channels to this one. + ...(accountTypes.includes(AccountType.Ica) + ? getIbcTransferChainIdsForChain(chainId) + : []), ].filter( (chainId) => !excludeChainIds?.includes(chainId) && diff --git a/packages/stateless/components/inputs/InputErrorMessage.tsx b/packages/stateless/components/inputs/InputErrorMessage.tsx index adc76f679..33daced98 100644 --- a/packages/stateless/components/inputs/InputErrorMessage.tsx +++ b/packages/stateless/components/inputs/InputErrorMessage.tsx @@ -28,7 +28,7 @@ export const InputErrorMessage = ({ return message ? ( , 'type' | 'variant'> { - hideIcon?: boolean - variant?: 'sm' | 'lg' - ghost?: boolean - onIconClick?: () => void - iconClassName?: string - containerClassName?: string -} +import { SearchBarProps } from '@dao-dao/types' export const SearchBar = forwardRef( function SearchBar( diff --git a/packages/stateless/components/inputs/SegmentedControlsTitle.tsx b/packages/stateless/components/inputs/SegmentedControlsTitle.tsx index 63e935778..1439cbc38 100644 --- a/packages/stateless/components/inputs/SegmentedControlsTitle.tsx +++ b/packages/stateless/components/inputs/SegmentedControlsTitle.tsx @@ -1,9 +1,10 @@ import clsx from 'clsx' import { FieldValues, Path, useFormContext } from 'react-hook-form' -import { SegmentedControls } from '@dao-dao/stateless' import { SegmentedControlsProps } from '@dao-dao/types' +import { SegmentedControls } from './SegmentedControls' + export type SegmentedControlsTitleProps< T extends unknown, FV extends FieldValues, diff --git a/packages/stateless/components/modals/DiscordNotifierConfigureModal.tsx b/packages/stateless/components/modals/DiscordNotifierConfigureModal.tsx index 5ae89455c..bbadcbc66 100644 --- a/packages/stateless/components/modals/DiscordNotifierConfigureModal.tsx +++ b/packages/stateless/components/modals/DiscordNotifierConfigureModal.tsx @@ -2,8 +2,7 @@ import { ArrowOutwardRounded, DeleteRounded } from '@mui/icons-material' import { ComponentType } from 'react' import { useTranslation } from 'react-i18next' -import { DiscordNotifierRegistration } from '@dao-dao/state/recoil' -import { ModalProps } from '@dao-dao/types' +import { DiscordNotifierRegistration, ModalProps } from '@dao-dao/types' import { useDaoInfoContext } from '../../contexts' import { Button } from '../buttons' diff --git a/packages/stateless/components/profile/ProfileActions.stories.tsx b/packages/stateless/components/profile/ProfileActions.stories.tsx index a8196403f..19da9adb1 100644 --- a/packages/stateless/components/profile/ProfileActions.stories.tsx +++ b/packages/stateless/components/profile/ProfileActions.stories.tsx @@ -2,7 +2,6 @@ import { ComponentMeta, ComponentStory } from '@storybook/react' import { useForm } from 'react-hook-form' import { SuspenseLoader, WalletChainSwitcher } from '@dao-dao/stateful' -import { useLoadedActionsAndCategories } from '@dao-dao/stateful/actions' import { WalletActionsProviderDecorator, WalletProviderDecorator, @@ -23,8 +22,6 @@ export default { } as ComponentMeta const Template: ComponentStory = (args) => { - const { loadedActions, categories } = useLoadedActionsAndCategories() - const formMethods = useForm({ mode: 'onChange', defaultValues: { @@ -32,14 +29,7 @@ const Template: ComponentStory = (args) => { }, }) - return ( - - ) + return } export const Default = Template.bind({}) @@ -48,7 +38,6 @@ Default.args = { console.log('execute!', data) alert('executed') }, - loading: false, SuspenseLoader, saves: { loading: false, @@ -79,8 +68,6 @@ Default.args = { WalletChainSwitcher, // Overwritten in template. - categories: [], - loadedActions: {}, formMethods: {} as any, } diff --git a/packages/stateless/components/profile/ProfileActions.tsx b/packages/stateless/components/profile/ProfileActions.tsx index ec5275021..56051eb58 100644 --- a/packages/stateless/components/profile/ProfileActions.tsx +++ b/packages/stateless/components/profile/ProfileActions.tsx @@ -20,20 +20,15 @@ import { useTranslation } from 'react-i18next' import { AccountTxForm, AccountTxSave, - ActionCategoryWithLabel, - LoadedActions, + ActionEncodeContext, LoadingData, SuspenseLoaderProps, UnifiedCosmosMsg, WalletChainSwitcherProps, } from '@dao-dao/types' -import { - convertActionsToMessages, - processError, - validateRequired, -} from '@dao-dao/utils' +import { encodeActions, processError, validateRequired } from '@dao-dao/utils' -import { useChainContext } from '../../contexts' +import { useActionsContext, useChainContext } from '../../contexts' import { useHoldingKey } from '../../hooks' import { ActionsEditor, RawActionsRenderer } from '../actions' import { Button, ButtonLink } from '../buttons' @@ -49,11 +44,8 @@ enum SubmitValue { } export type ProfileActionsProps = { - categories: ActionCategoryWithLabel[] - loadedActions: LoadedActions formMethods: UseFormReturn execute: (messages: UnifiedCosmosMsg[]) => Promise - loading: boolean SuspenseLoader: ComponentType error?: string txHash?: string @@ -63,14 +55,12 @@ export type ProfileActionsProps = { saving: boolean holdingAltForDirectSign: boolean WalletChainSwitcher: ComponentType + actionEncodeContext: ActionEncodeContext } export const ProfileActions = ({ - categories, - loadedActions, formMethods, execute, - loading, SuspenseLoader, error, txHash, @@ -80,9 +70,11 @@ export const ProfileActions = ({ saving, holdingAltForDirectSign, WalletChainSwitcher, + actionEncodeContext, }: ProfileActionsProps) => { const { t } = useTranslation() const { config } = useChainContext() + const { actionMap } = useActionsContext() const { handleSubmit, @@ -100,8 +92,10 @@ export const ProfileActions = ({ const holdingShiftForForce = useHoldingKey({ key: 'shift' }) + const [loading, setLoading] = useState(false) + const onSubmitForm: SubmitHandler = useCallback( - ({ actions }, event) => { + async ({ actions }, event) => { setShowSubmitErrorNote(false) setSubmitError('') @@ -113,9 +107,14 @@ export const ProfileActions = ({ return } - let msgs + setLoading(true) try { - msgs = convertActionsToMessages(loadedActions, actions) + const msgs = await encodeActions({ + actionMap, + encodeContext: actionEncodeContext, + data: actions, + }) + await execute(msgs) } catch (err) { console.error(err) setSubmitError( @@ -123,23 +122,27 @@ export const ProfileActions = ({ forceCapture: false, }) ) - return + } finally { + setLoading(false) } - - execute(msgs) }, - [execute, loadedActions] + [actionMap, actionEncodeContext, execute] ) const onSubmitError: SubmitErrorHandler = useCallback( - (errors) => { + async (errors) => { console.error('Form errors', errors) // Attempt submit anyways if forcing. if (holdingShiftForForce) { - let msgs + setLoading(true) try { - msgs = convertActionsToMessages(loadedActions, getValues('actions')) + const msgs = await encodeActions({ + actionMap, + encodeContext: actionEncodeContext, + data: getValues('actions'), + }) + await execute(msgs) } catch (err) { console.error(err) setSubmitError( @@ -147,18 +150,17 @@ export const ProfileActions = ({ forceCapture: false, }) ) - return + } finally { + setLoading(false) } - execute(msgs) - // If not forcing, show error to check for errors. } else { setShowSubmitErrorNote(true) setSubmitError('') } }, - [execute, getValues, holdingShiftForForce, loadedActions] + [actionMap, actionEncodeContext, execute, getValues, holdingShiftForForce] ) const [saveModalVisible, setSaveModalVisible] = useState(false) @@ -208,8 +210,6 @@ export const ProfileActions = ({ SuspenseLoader={SuspenseLoader} actionDataErrors={errors?.actions} actionDataFieldName="actions" - categories={categories} - loadedActions={loadedActions} />
@@ -299,8 +299,8 @@ export const ProfileActions = ({ {showPreview && ( )} diff --git a/packages/stateless/components/profile/ProfileCardWrapper.tsx b/packages/stateless/components/profile/ProfileCardWrapper.tsx index 22b216503..c52caf2d2 100644 --- a/packages/stateless/components/profile/ProfileCardWrapper.tsx +++ b/packages/stateless/components/profile/ProfileCardWrapper.tsx @@ -1,9 +1,7 @@ import clsx from 'clsx' -import { averageColorSelector } from '@dao-dao/state/recoil' import { ProfileCardWrapperProps } from '@dao-dao/types' -import { useCachedLoadable } from '../../hooks' import { CornerGradient } from '../CornerGradient' import { ProfileImage } from './ProfileImage' import { ProfileNameDisplayAndEditor } from './ProfileNameDisplayAndEditor' @@ -15,79 +13,61 @@ export const ProfileCardWrapper = ({ underHeaderComponent, childContainerClassName, className, -}: ProfileCardWrapperProps) => { - // Get average color of image URL if in compact mode. - const averageImgColorLoadable = useCachedLoadable( - !compact || profile.loading - ? undefined - : averageColorSelector(profile.data.imageUrl) - ) - const averageImgColor = - averageImgColorLoadable.state === 'hasValue' && - averageImgColorLoadable.contents - ? // If in #RRGGBB format, add ~20% opacity (0x33 = 51, 51/255 = 0.2). - averageImgColorLoadable.contents + - (averageImgColorLoadable.contents.length === 7 ? '33' : '') - : undefined + tintColor, +}: ProfileCardWrapperProps) => ( +
+ {/* Absolutely positioned, against relative outer-most div (without padding). */} + {compact && !!tintColor && ( + + )} - return ( -
- {/* Absolutely positioned, against relative outer-most div (without padding). */} - {compact && !!averageImgColor && ( - - )} - -
- {compact ? ( -
- +
+ {compact ? ( +
+ -
- - {underHeaderComponent} -
-
- ) : ( -
- - +
+ {underHeaderComponent}
- )} -
- - {children && ( -
- {children} +
+ ) : ( +
+ + + {underHeaderComponent}
)}
- ) -} + + {children && ( +
+ {children} +
+ )} +
+) diff --git a/packages/stateless/components/proposal/GovProposalStatus.tsx b/packages/stateless/components/proposal/GovProposalStatus.tsx index 715ee7dc1..1aa0fadb3 100644 --- a/packages/stateless/components/proposal/GovProposalStatus.tsx +++ b/packages/stateless/components/proposal/GovProposalStatus.tsx @@ -8,9 +8,10 @@ import { import { ReactElement } from 'react' import { useTranslation } from 'react-i18next' -import { StatusDisplay, StatusDisplayProps } from '@dao-dao/stateless' import { ProposalStatus } from '@dao-dao/types/protobuf/codegen/cosmos/gov/v1beta1/gov' +import { StatusDisplay, StatusDisplayProps } from '../StatusDisplay' + export type GovProposalStatusProps = { status: ProposalStatus } & Omit< diff --git a/packages/stateless/components/proposal/GovProposalVoteDisplay.tsx b/packages/stateless/components/proposal/GovProposalVoteDisplay.tsx index e58afabfd..364f1ee9b 100644 --- a/packages/stateless/components/proposal/GovProposalVoteDisplay.tsx +++ b/packages/stateless/components/proposal/GovProposalVoteDisplay.tsx @@ -1,9 +1,10 @@ import clsx from 'clsx' import { useTranslation } from 'react-i18next' -import { useGovProposalVoteOptions } from '@dao-dao/stateless' import { VoteOption } from '@dao-dao/types/protobuf/codegen/cosmos/gov/v1/gov' +import { useGovProposalVoteOptions } from '../../hooks' + export type GovProposalVoteDisplayProps = { vote: VoteOption } diff --git a/packages/stateless/components/proposal/NewProposal.tsx b/packages/stateless/components/proposal/NewProposal.tsx index f8c4364ae..2910bd152 100644 --- a/packages/stateless/components/proposal/NewProposal.tsx +++ b/packages/stateless/components/proposal/NewProposal.tsx @@ -57,13 +57,12 @@ export type NewProposalProps< } getProposalDataFromFormData: ( formData: UnpackNestedValue - ) => ProposalData + ) => Promise createProposal: (newProposalData: ProposalData) => Promise simulateProposal: (newProposalData: ProposalData) => Promise proposalTitle: string isWalletConnecting: boolean additionalSubmitError?: string - loading: boolean isPaused: boolean isActive: boolean activeThreshold: ActiveThreshold | null @@ -88,7 +87,6 @@ export const NewProposal = < proposalTitle, isWalletConnecting, additionalSubmitError, - loading, isPaused, isActive, activeThreshold, @@ -118,7 +116,9 @@ export const NewProposal = < const holdingAltForSimulation = useHoldingKey({ key: 'alt' }) const holdingShiftForForce = useHoldingKey({ key: 'shift' }) - const onSubmitForm: SubmitHandler = (formData, event) => { + const [loading, setLoading] = useState(false) + + const onSubmitForm: SubmitHandler = async (formData, event) => { setSubmitError('') const nativeEvent = event?.nativeEvent as SubmitEvent @@ -128,9 +128,14 @@ export const NewProposal = < return } - let data: ProposalData + setLoading(true) try { - data = getProposalDataFromFormData(formData) + const data = await getProposalDataFromFormData(formData) + if (holdingAltForSimulation) { + await simulateProposal(data) + } else { + await createProposal(data) + } } catch (err) { console.error(err) setSubmitError( @@ -138,22 +143,20 @@ export const NewProposal = < forceCapture: false, }) ) - return - } - - if (holdingAltForSimulation) { - simulateProposal(data) - } else { - createProposal(data) + } finally { + setLoading(false) } } - const onSubmitError: SubmitErrorHandler = () => { + const onSubmitError: SubmitErrorHandler = async (errors) => { + console.error('Form errors', errors) + // Even on error, try to simulate proposal. if (holdingAltForSimulation) { - let data: ProposalData + setLoading(true) try { - data = getProposalDataFromFormData(getValues()) + const data = await getProposalDataFromFormData(getValues()) + await simulateProposal(data) } catch (err) { console.error(err) setSubmitError( @@ -161,16 +164,16 @@ export const NewProposal = < forceCapture: false, }) ) - return + } finally { + setLoading(false) } - simulateProposal(data) - // Even on error, force publish if holding shift. } else if (holdingShiftForForce) { - let data: ProposalData + setLoading(true) try { - data = getProposalDataFromFormData(getValues()) + const data = await getProposalDataFromFormData(getValues()) + await createProposal(data) } catch (err) { console.error(err) setSubmitError( @@ -178,11 +181,10 @@ export const NewProposal = < forceCapture: false, }) ) - return + } finally { + setLoading(false) } - createProposal(data) - // If not simulating or forcing, show error to check for errors. } else { setSubmitError(t('error.correctErrorsAbove')) diff --git a/packages/stateless/components/proposal/PreProposeApprovalProposalStatus.tsx b/packages/stateless/components/proposal/PreProposeApprovalProposalStatus.tsx index b50fac6f4..8da0bda00 100644 --- a/packages/stateless/components/proposal/PreProposeApprovalProposalStatus.tsx +++ b/packages/stateless/components/proposal/PreProposeApprovalProposalStatus.tsx @@ -2,13 +2,14 @@ import { PendingOutlined, ThumbDown, ThumbUp } from '@mui/icons-material' import { ComponentType } from 'react' import { useTranslation } from 'react-i18next' -import { StatusDisplay, StatusDisplayProps } from '@dao-dao/stateless' import { ProposalStatus, ProposalStatusKey, } from '@dao-dao/types/contracts/DaoPreProposeApprovalSingle' import { keyFromPreProposeStatus } from '@dao-dao/utils' +import { StatusDisplay, StatusDisplayProps } from '../StatusDisplay' + export type PreProposeApprovalProposalStatusProps = { status: ProposalStatus } & Omit< diff --git a/packages/stateless/components/proposal/ProposalLine.tsx b/packages/stateless/components/proposal/ProposalLine.tsx index 3b70617bb..416f31516 100644 --- a/packages/stateless/components/proposal/ProposalLine.tsx +++ b/packages/stateless/components/proposal/ProposalLine.tsx @@ -20,6 +20,7 @@ export interface ProposalLineProps { vote: ReactNode href: string onClick?: () => void + openInNewTab?: boolean className?: string LinkWrapper: ComponentType approvalContext?: ApprovalProposalContext @@ -34,6 +35,7 @@ export const ProposalLine = ({ vote, href, onClick, + openInNewTab, className, LinkWrapper, approvalContext, @@ -45,6 +47,7 @@ export const ProposalLine = ({ )} href={href} onClick={onClick} + openInNewTab={openInNewTab} > {/* Desktop */}
diff --git a/packages/stateless/components/proposal/ProposalList.tsx b/packages/stateless/components/proposal/ProposalList.tsx index b0ad70785..00dac86e6 100644 --- a/packages/stateless/components/proposal/ProposalList.tsx +++ b/packages/stateless/components/proposal/ProposalList.tsx @@ -1,101 +1,24 @@ import { HowToVoteRounded } from '@mui/icons-material' import clsx from 'clsx' -import { ComponentType, useState } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { - DaoWithDropdownVetoableProposalList, - LinkWrapperProps, -} from '@dao-dao/types' +import { ProposalListProps } from '@dao-dao/types' import { useInfiniteScroll } from '../../hooks' import { Button } from '../buttons' import { Collapsible } from '../Collapsible' -import { SearchBar, SearchBarProps } from '../inputs' +import { ErrorPage } from '../error' +import { SearchBar } from '../inputs' import { LineLoaders } from '../LineLoader' import { NoContent } from '../NoContent' import { VetoableProposals } from './VetoableProposals' -type ProposalSection = { - /** - * The title of the section. - */ - title: string - /** - * The list of proposals in the section. - */ - proposals: T[] - /** - * The total number of proposals to display next to the title. This may be - * more than the number of proposals in the list due to pagination. - */ - total?: number - /** - * Whether or not the section is collapsed by default. Defaults to false. - */ - defaultCollapsed?: boolean -} - -export type ProposalListProps = { - /** - * Open proposals are shown at the top of the list. - */ - openProposals: T[] - /** - * DAOs with proposals that can be vetoed. Shown below open proposals. - */ - daosWithVetoableProposals: DaoWithDropdownVetoableProposalList[] - /** - * Proposal sections are shown below open and vetoable proposals. - */ - sections: ProposalSection[] - /** - * Link to create a new proposal. - */ - createNewProposalHref?: string - /** - * Whether or not there are more proposals to load. - */ - canLoadMore: boolean - /** - * Load more proposals. - */ - loadMore: () => void - /** - * Whether or not more proposals are being loaded. - */ - loadingMore: boolean - /** - * Whether or not the current wallet is a member of the DAO. - */ - isMember: boolean - /** - * DAO name. - */ - daoName: string - - ProposalLine: ComponentType - DiscordNotifierConfigureModal?: ComponentType | undefined - LinkWrapper: ComponentType - - /** - * Optionally display a search bar. - */ - searchBarProps?: SearchBarProps - /** - * Whether or not search results are showing. - */ - showingSearchResults?: boolean - /** - * Optional class name. - */ - className?: string -} - export const ProposalList = ({ openProposals, daosWithVetoableProposals, sections, + error, createNewProposalHref, canLoadMore, loadMore, @@ -108,6 +31,7 @@ export const ProposalList = ({ searchBarProps, showingSearchResults, className, + hideTitle, }: ProposalListProps) => { const { t } = useTranslation() @@ -129,17 +53,16 @@ export const ProposalList = ({ }) return ( -
-
-

{t('title.proposals')}

+
+ {!hideTitle && ( +
+

{t('title.proposals')}

- {DiscordNotifierConfigureModal && proposalsExist && ( - - )} -
+ {DiscordNotifierConfigureModal && proposalsExist && ( + + )} +
+ )} {searchBarProps && ( ({ )} {proposalsExist ? ( - <> +
{openProposals.length > 0 && ( -
+
{openProposals.map((props) => ( ))} @@ -163,48 +86,50 @@ export const ProposalList = ({ )} -
- {sections.map( - ({ title, proposals, total, defaultCollapsed }, index) => - proposals.length > 0 && ( - - {proposals.map((props) => ( - - ))} - - ) - )} -
+ {sections.length > 0 && ( +
+ {sections.map( + ({ title, proposals, total, defaultCollapsed }, index) => + proposals.length > 0 && ( + + {proposals.map((props) => ( + + ))} + + ) + )} +
+ )} {(canLoadMore || loadingMore) && lastCollapsibleSectionOpen && ( -
+
)} - +
) : // If loading but no proposals are loaded yet, show placeholders. loadingMore ? ( + ) : error ? ( + ) : ( void + setSelected: (proposalModule: ProposalModuleInfo) => void matchAdapter: ( contractNameToMatch: string ) => ProposalModuleAdapter | undefined @@ -48,7 +48,7 @@ export const ProposalModuleSelector = ({ ({ prePropose }) => prePropose?.type !== PreProposeModuleType.NeutronOverruleSingle ) - .map((proposalModule): TypedOption | undefined => { + .map((proposalModule): TypedOption | undefined => { const adapter = matchAdapter(proposalModule.contractName) return ( @@ -58,7 +58,7 @@ export const ProposalModuleSelector = ({ } ) }) - .filter((item): item is TypedOption => !!item) + .filter((item): item is TypedOption => !!item) // Ignore proposals with an approver pre-propose since those are // automatically managed by a pre-propose-approval contract in another // DAO. diff --git a/packages/stateless/components/proposal/ProposalStatus.tsx b/packages/stateless/components/proposal/ProposalStatus.tsx index cb2a0b8c7..697fb4526 100644 --- a/packages/stateless/components/proposal/ProposalStatus.tsx +++ b/packages/stateless/components/proposal/ProposalStatus.tsx @@ -11,7 +11,6 @@ import { import { ReactElement } from 'react' import { useTranslation } from 'react-i18next' -import { StatusDisplay, StatusDisplayProps } from '@dao-dao/stateless' import { ProposalStatusEnum, ProposalStatusKey, @@ -19,6 +18,8 @@ import { } from '@dao-dao/types' import { getProposalStatusKey } from '@dao-dao/utils' +import { StatusDisplay, StatusDisplayProps } from '../StatusDisplay' + export type ProposalStatusProps = { status: Status } & Omit< diff --git a/packages/stateless/components/proposal/ProposalVoter.tsx b/packages/stateless/components/proposal/ProposalVoter.tsx index 0f253197a..e5e023c60 100644 --- a/packages/stateless/components/proposal/ProposalVoter.tsx +++ b/packages/stateless/components/proposal/ProposalVoter.tsx @@ -14,8 +14,6 @@ export const ProposalVoter = ({ onCastVote, options, proposalOpen, - // If undefined, assume the user has seen all action pages. - seenAllActionPages = true, className, }: ProposalVoterProps) => { const { t } = useTranslation() @@ -37,30 +35,8 @@ export const ProposalVoter = ({ const currentVoteSelected = !!currentVoteOption && selectedVote === currentVoteOption.value - // Give actions a few seconds to render before showing unseen action pages - // warning. Actions take a moment to load state, match, and group accordingly, - // so the pages are not immediately available. - const [showUnseenActionPagesWarning, setShowUnseenActionPagesWarning] = - useState(false) - useEffect(() => { - const timeout = setTimeout(() => { - setShowUnseenActionPagesWarning(true) - }, 1000) - return () => clearTimeout(timeout) - }, []) - return (
- {/* If has not seen all action pages, and has not yet cast a vote, show warning. */} - {showUnseenActionPagesWarning && !seenAllActionPages && !currentVote && ( - - )} - {/* If proposal no longer open but voting is allowed, explain why. */} {!proposalOpen && ( ({ !selectedVote || // ...selected vote is already the current vote (possible when // revoting is allowed), - currentVoteSelected || - // ...or the user has not seen all action pages and has not yet voted. - (!seenAllActionPages && !currentVote) + currentVoteSelected } loading={loading} onClick={() => selectedVote && onCastVote(selectedVote)} diff --git a/packages/stateless/components/token/TokenCard.stories.tsx b/packages/stateless/components/token/TokenCard.stories.tsx index a6537fc9a..3669926c7 100644 --- a/packages/stateless/components/token/TokenCard.stories.tsx +++ b/packages/stateless/components/token/TokenCard.stories.tsx @@ -124,7 +124,7 @@ export const makeProps = (isGovernanceToken = false): TokenCardProps => { return { owner: { - type: AccountType.Native, + type: AccountType.Base, address: 'owner', chainId: CHAIN_ID, }, diff --git a/packages/stateless/components/token/TokenLine.tsx b/packages/stateless/components/token/TokenLine.tsx index 2cd338f34..88d0ad963 100644 --- a/packages/stateless/components/token/TokenLine.tsx +++ b/packages/stateless/components/token/TokenLine.tsx @@ -3,12 +3,6 @@ import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - ChainLogo, - Modal, - TokenAmountDisplay, - Tooltip, -} from '@dao-dao/stateless' import { TokenCardInfo, TokenLineProps } from '@dao-dao/types' import { getDisplayNameForChainId, @@ -17,6 +11,11 @@ import { toAccessibleImageUrl, } from '@dao-dao/utils' +import { ChainLogo } from '../chain' +import { Modal } from '../modals' +import { Tooltip } from '../tooltip' +import { TokenAmountDisplay } from './TokenAmountDisplay' + export const TokenLine = ( props: TokenLineProps ) => { diff --git a/packages/stateless/components/vesting/VestingPaymentCard.stories.tsx b/packages/stateless/components/vesting/VestingPaymentCard.stories.tsx index aa8320c96..3ae081dc9 100644 --- a/packages/stateless/components/vesting/VestingPaymentCard.stories.tsx +++ b/packages/stateless/components/vesting/VestingPaymentCard.stories.tsx @@ -1,11 +1,11 @@ import { ComponentMeta, ComponentStory } from '@storybook/react' import { ButtonLink, EntityDisplay } from '@dao-dao/stateful' -import { makeProps as makeTokenCardProps } from '@dao-dao/stateless/components/token/TokenCard.stories' import { CHAIN_ID } from '@dao-dao/storybook' import { DaoPageWrapperDecorator } from '@dao-dao/storybook/decorators/DaoPageWrapperDecorator' import { EntityType, TokenType } from '@dao-dao/types' +import { makeProps as makeTokenCardProps } from '../../components/token/TokenCard.stories' import { VestingPaymentCard } from './VestingPaymentCard' export default { diff --git a/packages/stateless/components/vesting/VestingPaymentLine.tsx b/packages/stateless/components/vesting/VestingPaymentLine.tsx index df45359af..1c15f9503 100644 --- a/packages/stateless/components/vesting/VestingPaymentLine.tsx +++ b/packages/stateless/components/vesting/VestingPaymentLine.tsx @@ -2,12 +2,6 @@ import clsx from 'clsx' import { useTranslation } from 'react-i18next' import TimeAgo from 'react-timeago' -import { - ChainProvider, - TokenAmountDisplay, - Tooltip, - useTranslatedTimeDeltaFormatter, -} from '@dao-dao/stateless' import { VestingPaymentLineProps } from '@dao-dao/types' import { convertMicroDenomToDenomWithDecimals, @@ -15,6 +9,11 @@ import { formatDateTimeTz, } from '@dao-dao/utils' +import { useTranslatedTimeDeltaFormatter } from '../../hooks' +import { ChainProvider } from '../chain' +import { TokenAmountDisplay } from '../token' +import { Tooltip } from '../tooltip' + export const VestingPaymentLine = ({ vestingInfo, onClick, diff --git a/packages/stateless/contexts/ActionMatcher.ts b/packages/stateless/contexts/ActionMatcher.ts new file mode 100644 index 000000000..2285088cd --- /dev/null +++ b/packages/stateless/contexts/ActionMatcher.ts @@ -0,0 +1,24 @@ +import { createContext, useContext } from 'react' + +import { IActionMatcherContext } from '@dao-dao/types/actions' + +export const ActionMatcherContext = createContext( + null +) + +export const useActionMatcherContext = (): IActionMatcherContext => { + const context = useContext(ActionMatcherContext) + + if (!context) { + throw new Error( + 'useActionMatcherContext can only be used in a descendant of ActionMatcherProvider.' + ) + } + + return context +} + +/** + * Get the action matcher. + */ +export const useActionMatcher = () => useActionMatcherContext().matcher diff --git a/packages/stateless/contexts/Actions.ts b/packages/stateless/contexts/Actions.ts new file mode 100644 index 000000000..435996aca --- /dev/null +++ b/packages/stateless/contexts/Actions.ts @@ -0,0 +1,67 @@ +import { createContext, useContext } from 'react' + +import { LoadingDataWithError } from '@dao-dao/types' +import { Action, ActionKey, IActionsContext } from '@dao-dao/types/actions' + +import { useLoadingPromise } from '../hooks' + +export const ActionsContext = createContext(null) + +export const useActionsContext = (): IActionsContext => { + const context = useContext(ActionsContext) + + if (!context) { + throw new Error( + 'useActionsContext can only be used in a descendant of an Actions provider.' + ) + } + + return context +} + +/** + * Get the options passed to actions. + */ +export const useActionOptions = () => useActionsContext().options + +/** + * Get all relevant actions. + */ +export const useActions = () => useActionsContext().actions + +/** + * Get all relevant action categories. + */ +export const useActionCategories = () => useActionsContext().categories + +/** + * Get an action from its key, if its valid in the current context. Only core + * actions are always provided. Adapter-specific actions may be available but + * are not guaranteed based on the context. + */ +export const useActionForKey = ( + actionKey: ActionKey +): Action | undefined => useActionsContext().actionMap[actionKey] + +/** + * Get an action from its key, if its valid in the current context, making sure + * to initialize it. Only core actions are always provided. Adapter-specific + * actions may be available but are not guaranteed based on the context. + */ +export const useInitializedActionForKey = ( + ...params: Parameters +): LoadingDataWithError> => { + const action = useActionForKey(...params) + return useLoadingPromise({ + promise: async () => { + if (!action) { + throw new Error('Action not found') + } + if (!action.ready) { + await action.init() + } + return action + }, + deps: [action, action?.status], + }) +} diff --git a/packages/stateless/contexts/ActionsEncoder.ts b/packages/stateless/contexts/ActionsEncoder.ts new file mode 100644 index 000000000..c28c933d0 --- /dev/null +++ b/packages/stateless/contexts/ActionsEncoder.ts @@ -0,0 +1,23 @@ +import { createContext, useContext } from 'react' + +import { IActionsEncoderContext } from '@dao-dao/types/actions' + +export const ActionsEncoderContext = + createContext(null) + +export const useActionsEncoderContext = (): IActionsEncoderContext => { + const context = useContext(ActionsEncoderContext) + + if (!context) { + throw new Error( + 'useActionsEncoderContext can only be used in a descendant of ActionsEncoderProvider.' + ) + } + + return context +} + +/** + * Get the actions encoder. + */ +export const useActionsEncoder = () => useActionsEncoderContext().encoder diff --git a/packages/stateless/contexts/index.ts b/packages/stateless/contexts/index.ts index b25e3a3c8..4b776784d 100644 --- a/packages/stateless/contexts/index.ts +++ b/packages/stateless/contexts/index.ts @@ -1,2 +1,5 @@ +export * from './ActionMatcher' +export * from './Actions' +export * from './ActionsEncoder' export * from './Chain' export * from './Dao' diff --git a/packages/stateless/hooks/useLoadingPromise.ts b/packages/stateless/hooks/useLoadingPromise.ts index be770deba..239bfebb7 100644 --- a/packages/stateless/hooks/useLoadingPromise.ts +++ b/packages/stateless/hooks/useLoadingPromise.ts @@ -43,20 +43,6 @@ export const useLoadingPromise = ({ const promiseRef = useUpdatingRef(_promise) const promiseIsDefined = !!_promise - const promise = useMemo( - () => promiseRef.current?.(), - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - // Use memoized ref so it doesn't reset on every render. - promiseRef, - // Update if the promise switches between a function and undefined so that - // the loading state updates immediately. - promiseIsDefined, - // eslint-disable-next-line react-hooks/exhaustive-deps - ...(deps || []), - ] - ) - // Load promise when it changes. useEffect(() => { setState((s) => ({ @@ -64,7 +50,8 @@ export const useLoadingPromise = ({ status: 'loading', })) - promise + promiseRef + .current?.() ?.then((value) => setState({ status: 'success', @@ -81,7 +68,15 @@ export const useLoadingPromise = ({ was: 'error', }) ) - }, [promise]) + }, [ + // Use memoized ref so it doesn't reset on every render. + promiseRef, + // Update if the promise switches between a function and undefined so that + // the loading state updates immediately. + promiseIsDefined, + // eslint-disable-next-line react-hooks/exhaustive-deps + ...(deps || []), + ]) return useMemo( (): LoadingDataWithError => diff --git a/packages/stateless/hooks/useSearchFilter.ts b/packages/stateless/hooks/useSearchFilter.ts index bd6d2621b..6ed7dfef8 100644 --- a/packages/stateless/hooks/useSearchFilter.ts +++ b/packages/stateless/hooks/useSearchFilter.ts @@ -10,7 +10,8 @@ import { useState, } from 'react' -import { SearchBarProps } from '../components' +import { SearchBarProps } from '@dao-dao/types' + import { useQuerySyncedState } from './useQuerySyncedState' import { useUpdatingRef } from './useUpdatingRef' diff --git a/packages/stateless/index.ts b/packages/stateless/index.ts index 934e504dd..bb33d4ac5 100644 --- a/packages/stateless/index.ts +++ b/packages/stateless/index.ts @@ -1,5 +1,7 @@ +export * from './actions' export * from './components' export * from './contexts' export * from './hooks' export * from './pages' +export * from './providers' export * from './theme' diff --git a/packages/stateless/providers/ActionMatcher.tsx b/packages/stateless/providers/ActionMatcher.tsx new file mode 100644 index 000000000..98b0da4ce --- /dev/null +++ b/packages/stateless/providers/ActionMatcher.tsx @@ -0,0 +1,53 @@ +import { ReactNode, useEffect, useState } from 'react' + +import { UnifiedCosmosMsg } from '@dao-dao/types' + +import { ActionMatcher } from '../actions' +import { ActionMatcherContext, useActionsContext } from '../contexts' +import { useLoadingPromise } from '../hooks' + +export type ActionMatcherProviderProps = { + /** + * The messages to match. + */ + messages: UnifiedCosmosMsg[] + /** + * The children to render. + */ + children: ReactNode +} + +export const ActionMatcherProvider = ({ + messages, + children, +}: ActionMatcherProviderProps) => { + const { options, actions, messageProcessor } = useActionsContext() + + const [matcher] = useState( + () => new ActionMatcher(options, messageProcessor, actions) + ) + // Update fields whenever they change, preserving the matcher instance. + useEffect(() => { + matcher.options = options + matcher.messageProcessor = messageProcessor + matcher.actions = actions + }, [matcher, options, messageProcessor, actions]) + + // Match the messages whenever they change. All descendants will re-render + // when this promise resolves or errors, so descendants should immediately + // render updates to the matcher class instance's state. + useLoadingPromise({ + promise: () => matcher.match(messages), + deps: [matcher, messages], + }) + + return ( + + {children} + + ) +} diff --git a/packages/stateless/providers/ActionsEncoder.tsx b/packages/stateless/providers/ActionsEncoder.tsx new file mode 100644 index 000000000..873aecbbc --- /dev/null +++ b/packages/stateless/providers/ActionsEncoder.tsx @@ -0,0 +1,53 @@ +import { ReactNode, useMemo } from 'react' + +import { ActionEncodeContext, ActionKeyAndDataNoId } from '@dao-dao/types' + +import { ActionsEncoder } from '../actions' +import { ActionsEncoderContext, useActions } from '../contexts' +import { useLoadingPromise } from '../hooks' + +export type ActionsEncoderProviderProps = { + /** + * Encode context. + */ + encodeContext: ActionEncodeContext + /** + * Optionally immediately encode action keys and data. If undefined, encoder + * will start in initialized state. + */ + actionKeysAndData?: ActionKeyAndDataNoId[] + /** + * The children to render. + */ + children: ReactNode +} + +export const ActionsEncoderProvider = ({ + encodeContext, + actionKeysAndData, + children, +}: ActionsEncoderProviderProps) => { + const actions = useActions() + const encoder = useMemo( + () => new ActionsEncoder(encodeContext, actions), + [encodeContext, actions] + ) + + // Encode the actions whenever they change. All descendants will re-render + // when this promise resolves or errors, so descendants should immediately + // render updates to the encoder class instance's state. + useLoadingPromise({ + promise: actionKeysAndData && (() => encoder.encode(actionKeysAndData)), + deps: [encoder, actionKeysAndData && JSON.stringify(actionKeysAndData)], + }) + + return ( + + {children} + + ) +} diff --git a/packages/stateless/providers/index.ts b/packages/stateless/providers/index.ts new file mode 100644 index 000000000..6eac6e351 --- /dev/null +++ b/packages/stateless/providers/index.ts @@ -0,0 +1,2 @@ +export * from './ActionMatcher' +export * from './ActionsEncoder' diff --git a/packages/storybook/.env b/packages/storybook/.env index 99d44bdef..47380966b 100644 --- a/packages/storybook/.env +++ b/packages/storybook/.env @@ -24,9 +24,6 @@ NEXT_PUBLIC_RETROACTIVE_COMPENSATION_API_BASE=https://retroactive-compensation.d NEXT_PUBLIC_FAST_AVERAGE_COLOR_API_TEMPLATE=https://fac.withoutdoing.com/URL -# Comma separated list of action keys to disable. -NEXT_PUBLIC_DISABLED_ACTIONS= - # Discord notifier NEXT_PUBLIC_DISCORD_NOTIFIER_CLIENT_ID=1060326264801595402 NEXT_PUBLIC_DISCORD_NOTIFIER_API_BASE=https://discord-notifier.daodao.zone diff --git a/packages/storybook/decorators/DaoPageWrapperDecorator.tsx b/packages/storybook/decorators/DaoPageWrapperDecorator.tsx index 08f03f974..d9a3bae1c 100644 --- a/packages/storybook/decorators/DaoPageWrapperDecorator.tsx +++ b/packages/storybook/decorators/DaoPageWrapperDecorator.tsx @@ -60,7 +60,7 @@ export const makeDaoInfo = (): DaoInfo => ({ polytoneProxies: {}, accounts: [ { - type: AccountType.Native, + type: AccountType.Base, chainId: ChainId.JunoMainnet, address: 'junoDaoCoreAddress', }, diff --git a/packages/types/account.ts b/packages/types/account.ts index 1d3c72bb8..2c504432e 100644 --- a/packages/types/account.ts +++ b/packages/types/account.ts @@ -10,9 +10,10 @@ import { GenericToken } from './token' */ export enum AccountType { /** - * context. + * The base account given the context. This is the primary account, in control + * of all the other accounts. It is likely the DAO core, a wallet, etc. */ - Native = 'native', + Base = 'base', /** * A Polytone account controlled by an account on another chain. */ @@ -33,29 +34,33 @@ export enum AccountType { * A Timewave Valence account. */ Valence = 'valence', + /** + * A cw1-whitelist smart contract. + */ + Cw1Whitelist = 'cw1Whitelist', } -export type BaseAccount = { +export type CommonAccount = { chainId: string address: string } -export type NativeAccount = BaseAccount & { - type: AccountType.Native +export type BaseAccount = CommonAccount & { + type: AccountType.Base config?: undefined } -export type PolytoneAccount = BaseAccount & { +export type PolytoneAccount = CommonAccount & { type: AccountType.Polytone config?: undefined } -export type IcaAccount = BaseAccount & { +export type IcaAccount = CommonAccount & { type: AccountType.Ica config?: undefined } -export type CryptographicMultisigAccount = BaseAccount & { +export type CryptographicMultisigAccount = CommonAccount & { type: AccountType.CryptographicMultisig config: { /** @@ -76,19 +81,20 @@ export type CryptographicMultisigAccount = BaseAccount & { } } -export type Cw3MultisigAccount = BaseAccount & { +export type Cw3MultisigAccount = CommonAccount & { type: AccountType.Cw3Multisig config: CryptographicMultisigAccount['config'] } export type MultisigAccount = CryptographicMultisigAccount | Cw3MultisigAccount -export type ValenceAccount = BaseAccount & { +export type ValenceAccount = CommonAccount & { type: AccountType.Valence config: ValenceAccountConfig } export type ValenceAccountConfig = { + admin: string // If rebalancer setup, this will be defined. rebalancer: { config: RebalancerConfig @@ -110,13 +116,24 @@ export type ValenceAccountRebalancerTarget = { } & ParsedTarget)[] } +export type Cw1WhitelistAccount = CommonAccount & { + type: AccountType.Cw1Whitelist + config: { + /** + * The cw1-whitelist admins. + */ + admins: string[] + } +} + export type Account = - | NativeAccount + | BaseAccount | PolytoneAccount | IcaAccount | CryptographicMultisigAccount | Cw3MultisigAccount | ValenceAccount + | Cw1WhitelistAccount /** * Unique identifier for account tabs, which is used in the URL path. diff --git a/packages/types/actions.ts b/packages/types/actions.ts index e98627896..f280f1051 100644 --- a/packages/types/actions.ts +++ b/packages/types/actions.ts @@ -1,5 +1,6 @@ // eslint-disable-next-line regex/invalid import { Chain } from '@chain-registry/types' +import { QueryClient } from '@tanstack/react-query' import { ComponentType, ReactNode } from 'react' import { FieldErrors } from 'react-hook-form' import { TFunction } from 'react-i18next' @@ -10,10 +11,11 @@ import { IChainContext, SupportedChainContext, } from './chain' -import { IDaoBase } from './clients' +import { IDaoBase, IProposalModuleBase } from './clients' import { UnifiedCosmosMsg } from './contracts/common' import { AllGovParams } from './gov' import { UnifiedProfile } from './profile' +import { DecodedIcaMsgMatch, DecodedPolytoneMsgMatch } from './proposal' export enum ActionCategoryKey { CommonlyUsed = 'commonlyUsed', @@ -69,22 +71,21 @@ export enum ActionKey { FeeShare = 'feeShare', ManageMembers = 'manageMembers', Mint = 'mint', - UpdateMinterAllowance = 'updateMinterAllowance', ManageVesting = 'manageVesting', CreateCrossChainAccount = 'createCrossChainAccount', CrossChainExecute = 'crossChainExecute', UpdateStakingConfig = 'updateStakingConfig', CreateIca = 'createIca', IcaExecute = 'icaExecute', - ManageIcas = 'manageIcas', - VetoOrEarlyExecuteDaoProposal = 'vetoOrEarlyExecuteDaoProposal', + HideIca = 'hideIca', + VetoProposal = 'vetoProposal', + ExecuteProposal = 'executeProposal', NeutronOverruleSubDaoProposal = 'neutronOverruleSubDaoProposal', ManageVetoableDaos = 'manageVetoableDaos', UploadCode = 'uploadCode', ManageSubDaoPause = 'manageSubDaoPause', UpdatePreProposeConfig = 'updatePreProposeConfig', UpdateProposalConfig = 'updateProposalConfig', - MigrateMigalooV4TokenFactory = 'migrateMigalooV4TokenFactory', CreateDao = 'createDao', // Valence CreateValenceAccount = 'createValenceAccount', @@ -108,9 +109,11 @@ export enum ActionKey { BecomeSubDao = 'becomeSubDao', } -export type ActionAndData = { - action: Action - data: any +export type ActionAndData< + Data extends Record = Record +> = { + action: Action + data: Data } export type ActionKeyAndData = { @@ -140,7 +143,7 @@ export type ActionComponentProps = { action: ActionKeyAndDataNoId, // If omitted, the action will be appened to the end of the list. insertIndex?: number - ) => void + ) => void | Promise // Removes this action from the form. remove: () => void } @@ -159,80 +162,170 @@ export type ActionComponent = ComponentType< > /** - * A hook that returns the default values for an action. If it returns an error, - * the action should not be added because some critical data failed to load. If - * it returns undefined, the action is loading and should not allowed to be - * added until the default values are loaded. + * The match result for an action given a list of messages. + * + * If this is a `number`, it corresponds to how many messages from the start of + * the list are matched by this action. If this is a `boolean`, it corresponds + * with either `1` or `0` messages matched, for `true` and `false`, + * respectively. A truthy value always corresponds with a match, and falsy + * always corresponds with no match. + * + * Recommended usage is `false` for no match, `true` for one message, and a + * number for more than one message. */ -export type UseDefaults = () => D | Error | undefined - -export type UseTransformToCosmos = () => ( - data: D -) => UnifiedCosmosMsg | undefined +export type ActionMatch = boolean | number -export interface DecodeCosmosMsgNoMatch { - match: false - data?: never -} -export interface DecodeCosmosMsgMatch { - match: true - data: D +export interface Action< + Data extends Record = Record +> { + /** + * The unique key identifying the action. + */ + key: ActionKey + /** + * Action component to edit/view the data. + */ + Component: ActionComponent + /** + * The metadata describing an action. + */ + metadata: { + /** + * The icon to display in the card. + */ + Icon: ComponentType + /** + * The label to display. + */ + label: string + /** + * The description to display in the picker. + */ + description: string + /** + * Optional keywords to improve search results. + */ + keywords?: string[] + /** + * This determines if the action should be hidden from creation. If true, + * the action will not be shown in the list of actions to create, but it + * will still match and render in existing contexts. This is used to + * conditionally show the upgrade actions while still allowing them to + * render in existing proposals and be added programmatically during + * creation. + */ + hideFromPicker?: boolean + /** + * Whether or not this action is reusable. Defaults to false. If true, when + * editing the action, the add and remove button in the group will be + * removed, and the action will be hidden from future category picker + * selections. Some actions, like 'Spend', make sense to use multiple times, + * while others, like 'Update Info' or any configuration updater, should + * only be used once at a time. We should prevent users from adding multiple + * of these actions. + */ + notReusable?: boolean + /** + * Programmatic actions cannot be chosen or removed by the user. This is + * used for actions should only be controlled by code. The user should not + * be able to modify it at all, which also means the user cannot pick this + * action or go back to the category action picker. This includes both + * `hideFromPicker` and `notReusable`, while also preventing the user from + * going back to the category action picker or removing the action. + */ + programmaticOnly?: boolean + /** + * Order of this action in the list of actions. A greater number will be + * shown first. If no order specified, actions will be sorted based on their + * position in the category definition. + */ + listOrder?: number + /** + * Priority with which successful matches get assigned to this action. A + * greater number will be matched first. Equal priorities should be treated + * as non-deterministic. + * + * Default is 0. + */ + matchPriority?: number + } + /** + * Function to initialize the action. This updates status/error and loads + * defaults. It may also update metadata, such as `hideFromPicker`. + */ + init: () => void | Promise + /** + * Status of the action. Starts at 'idle'. If 'idle', 'loading' or 'error', + * the action is not ready to be used, and attempting to access `defaults` + * will throw an error. If 'error', the action should not be used. If 'ready', + * the action is ready to be used. + */ + status: 'idle' | 'loading' | 'error' | 'ready' + /** + * Whether or not the action is loading. + */ + get loading(): boolean + /** + * Whether or not the action is errored. + */ + get errored(): boolean + /** + * Whether or not the action is ready. + */ + get ready(): boolean + /** + * If status is 'error', this will be the error that caused the action to + * fail. Otherwise, it will be undefined. + */ + error?: Error + /** + * Default data for the action. This will work only when status is 'ready'. + * Otherwise, it will throw an error. + */ + defaults: Data + /** + * Function to transform action data into one or more Cosmos messages. This + * should only be called if the action is ready. + */ + encode: ( + data: Data, + context: ActionEncodeContext + ) => + | UnifiedCosmosMsg + | UnifiedCosmosMsg[] + | Promise + /** + * Function to determine if this action exists at the start of the list of + * messages. This should only be called if the action is ready. + */ + match: (messages: ProcessedMessage[]) => ActionMatch | Promise + /** + * Function to transform Cosmos messages into action data. It can be partial + * data, which will be applied to the defaults as a base. This should only be + * called if the action is ready. + */ + decode: ( + messages: ProcessedMessage[] + ) => Partial | Promise> + /** + * Optional function to transform data from a bulk import into the action's + * data shape. This can be used to help coerce certain data types, such as + * strings into numbers. + */ + transformImportData?: (data: any) => Data } -export type UseDecodedCosmosMsg = ( - msg: Record -) => DecodeCosmosMsgNoMatch | DecodeCosmosMsgMatch - -export type UseHideFromPicker = () => boolean -export interface Action { - key: ActionKey - Icon: ComponentType - label: string - description: string - // Optional keywords to improve search results. - keywords?: string[] - Component: ActionComponent - // This determines if the action should be hidden from creation. If true, the - // action will not be shown in the list of actions to create, but it will - // still match and render in existing contexts. This is used to conditionally - // show the upgrade actions while still allowing them to render in existing - // proposals and be added programmatically during creation. - hideFromPicker?: boolean - // Whether or not this action is reusable. Defaults to false. If true, when - // editing the action, the add and remove button in the group will be removed, - // and the action will be hidden from future category picker selections. Some - // actions, like 'Spend', make sense to use multiple times, while others, like - // 'Update Info' or any configuration updater, should only be used once at a - // time. We should prevent users from adding multiple of these actions. - notReusable?: boolean - // Programmatic actions cannot be chosen or removed by the user. This is used - // for actions should only be controlled by code. The user should not be able - // to modify it at all, which also means the user cannot pick this action or - // go back to the category action picker. This includes both `hideFromPicker` - // and `notReusable`, while also preventing the user from going back to the - // category action picker or removing the action. - programmaticOnly?: boolean - /** - * Order of this action in the list of actions. A greater number will be shown - * first. If no order specified, actions will be sorted based on their - * position in the category definition. - */ - order?: number - // Hook to get default fields for form display. - useDefaults: UseDefaults - // Hook to make function to convert action data to UnifiedCosmosMsg. - useTransformToCosmos: UseTransformToCosmos - // Hook to make function to convert decoded msg to form display fields. - useDecodedCosmosMsg: UseDecodedCosmosMsg - // Hook to determine whether or not the action should be hidden from the - // creation picker. If true, the action will not be shown in the list of - // actions to create, but it will still match and render in existing contexts. - // This is a hook version of the `hideFromPicker` option above. If either is - // true, it will be hidden. - useHideFromPicker?: UseHideFromPicker +/** + * A class that can be constructed to create an action. Action is the interface, + * and this is the type of a class that implements it. + */ +export type ImplementedAction< + Data extends Record = Record +> = { + new (options: ActionOptions): Action } -export type ActionCategory = { +export type ActionCategoryBase = { // If many categories exist with the same key, they will be merged. The first // defined label and description will be used. This allows additional modules // to add actions to the same category without changing any metadata. @@ -241,10 +334,10 @@ export type ActionCategory = { description?: string // Optional keywords to improve search results. keywords?: string[] - actions: Action[] + actionKeys: ActionKey[] } -export type ActionCategoryWithLabel = Omit & { +export type ActionCategory = Omit & { label: string } @@ -273,6 +366,23 @@ export type ActionContext = ( accounts: Account[] } +/** + * Additional context passed to the encode function. + */ +export type ActionEncodeContext = + | { + type: ActionContextType.Dao + dao: IDaoBase + proposalModule: IProposalModuleBase + } + | { + type: ActionContextType.Wallet + } + | { + type: ActionContextType.Gov + params: AllGovParams + } + export enum ActionChainContextType { /** * Any chain, not configured. @@ -309,32 +419,71 @@ export type ActionOptions = ExtraOptions & { // x/gov module address if context.type === Gov address: string context: ActionContext + queryClient: QueryClient } -export type ActionMaker = ( - options: ActionOptions -) => Action | null - -// A category maker can return null to indicate that the category should not be -// included. It can also return either actions, action makers, or both. This is -// convience to avoid every category maker needing the same boilerplate action -// maker code. `actionMakers` will be made into actions and merged with -// `actions`. If no actions exist after all are made, the category will be -// ignored. +export type ActionMaker< + Data extends Record = Record, + ExtraOptions extends {} = {} +> = (options: ActionOptions) => Action | null + +/** + * A category maker can return null to indicate that the category should not be + * included. + */ export type ActionCategoryMaker = ( options: ActionOptions -) => - | (Omit & { - actions?: Action[] - actionMakers?: ActionMaker[] - }) - | null +) => ActionCategoryBase | null -// React context/provider system for actions. +/** + * Map action key to action. + */ +export type ActionMap = Record +/** + * React context for actions that are available to use. + */ export type IActionsContext = { + /** + * Action options. + */ options: ActionOptions - categories: ActionCategoryWithLabel[] + /** + * List of all actions. + */ + actions: Action[] + /** + * Map action key to action. + */ + actionMap: ActionMap + /** + * List of all action categories. + */ + categories: ActionCategory[] + /** + * Action message procesor. + */ + messageProcessor: MessageProcessor +} + +/** + * React context for actions being matched. + */ +export type IActionMatcherContext = { + /** + * Action matcher. + */ + matcher: IActionMatcher +} + +/** + * React context for actions being encoded. + */ +export type IActionsEncoderContext = { + /** + * Action encoder. + */ + encoder: IActionsEncoder } export type UseActionsOptions = { @@ -344,14 +493,6 @@ export type UseActionsOptions = { isCreating?: boolean } -export type LoadedAction = { - category: ActionCategoryWithLabel - action: Action - transform: ReturnType - defaults: ReturnType -} -export type LoadedActions = Partial> - export type NestedActionsEditorFormData = { msgs: UnifiedCosmosMsg[] @@ -374,3 +515,209 @@ export type GovActionsProviderProps = ActionsProviderProps & { */ loader?: ReactNode } + +/** + * Action decoder for a single action and set of matched messages. + */ +export interface IActionDecoder< + Data extends Record = Record +> { + /** + * The action that matched the messages. + */ + action: Action + /** + * The messages. + */ + messages: ProcessedMessage[] + /** + * Status of decoder. + */ + status: 'idle' | 'loading' | 'error' | 'ready' + /** + * Function to decode messages into action data. + * @returns A promise that resolves to the decoded action data. + */ + decode: () => Promise + /** + * Decoded action data. Throw an error if not yet decoded. + */ + get data(): Data + /** + * Whether or not the decoder is loading. + */ + get loading(): boolean + /** + * Whether or not the decoder is errored. + */ + get errored(): boolean + /** + * Whether or not the decoder is ready. + */ + get ready(): boolean + /** + * Error if the decoder errored. Throw an error if not yet errored. + */ + get error(): Error +} + +/** + * Action matcher. + */ +export interface IActionMatcher { + /** + * Status of matcher. + */ + status: 'idle' | 'loading' | 'error' | 'ready' + /** + * Function to match messages with actions and create decoders for them. + * @param messages - Array of `UnifiedCosmosMsg` to be matched. + * @returns A promise that resolves to an array of `IActionDecoder`. + */ + match: (messages: UnifiedCosmosMsg[]) => Promise + /** + * Action decoders for matched messages. Throw an error if not yet matched. + */ + get matches(): IActionDecoder[] + /** + * Whether or not the matcher is idle, meaning it hasn't attempted to match + * yet. + */ + get idle(): boolean + /** + * Whether or not the matcher is loading. + */ + get loading(): boolean + /** + * Whether or not the matcher is errored. + */ + get errored(): boolean + /** + * Whether or not the matcher is ready. + */ + get ready(): boolean + /** + * Error if the matcher errored. Throw an error if not yet errored. + */ + get error(): Error +} + +/** + * Actions encoder. + */ +export interface IActionsEncoder { + /** + * Status of encoder. + */ + status: 'idle' | 'loading' | 'error' | 'ready' + /** + * Function to encode actions with data into messages. + * @param actionKeysAndData - Array of `ActionKeyAndDataNoId` to be encoded. + * @returns A promise that resolves to an array of `UnifiedCosmosMsg`. + */ + encode: ( + actionKeysAndData: ActionKeyAndDataNoId[] + ) => Promise + /** + * Encoded messages. Throw an error if not yet encoded. + */ + get messages(): UnifiedCosmosMsg[] + /** + * Whether or not the encoder is idle, meaning it hasn't attempted to encode + * yet. + */ + get idle(): boolean + /** + * Whether or not the encoder is loading. + */ + get loading(): boolean + /** + * Whether or not the encoder is errored. + */ + get errored(): boolean + /** + * Whether or not the encoder is ready. + */ + get ready(): boolean + /** + * Error if the encoder errored. Throw an error if not yet errored. + */ + get error(): Error +} + +/** + * A message that has been processed. + */ +export type ProcessedMessage = { + /** + * The message that was executed. + */ + message: UnifiedCosmosMsg + /** + * The account that executed the message. + */ + account: Account + /** + * Whether or not this is a cross-chain message, meaning this was a wrapped + * Polytone or ICA execute message. The account's chain ID should differ from + * the sender's chain ID. + */ + isCrossChain: boolean + /** + * Whether or not this is a wrapped execute message, which is a message known + * to execute messages as another account. + */ + isWrapped: boolean + /** + * The processed wrapped messages if any exist. + */ + wrappedMessages: ProcessedMessage[] + /** + * The decoded message with accessible fields, or if this is a wrapped execute + * message (such as a cross-chain or cw1-whitelist execute), the first wrapped + * decoded message. If this is a wrapped execute but there are no wrapped + * messages, this is null. See the `decodeMessage` util function for more + * information. + */ + decodedMessage: any + /** + * The decoded messages with accessible fields. If this is a wrapped execute + * message (such as a cross-chain or cw1-whitelist execute), these are the + * wrapped decoded messages. If not a wrapped message, this will be an array + * with just the main decoded message in it. See the `decodeMessage` util + * function for more information. + */ + decodedMessages: any[] + /** + * If this was a wrapped Polytone execute, this is the decoded Polytone match. + */ + polytone?: DecodedPolytoneMsgMatch + /** + * If this was a wrapped ICA execute, this is the decoded ICA match. + */ + ica?: DecodedIcaMsgMatch +} + +/** + * Process a single message, detecting the account that sent the message, and + * parsing wrapped executions (such as cross-chain messages, cw1-whitelist + * executions, etc.). + */ +export type MessageProcessor = (options: { + /** + * The chain the message was executed on. + */ + chainId: string + /** + * The sender of the message. + */ + sender: string + /** + * The message to process. + */ + message: UnifiedCosmosMsg + /** + * The query client. + */ + queryClient: QueryClient +}) => Promise diff --git a/packages/types/chain.ts b/packages/types/chain.ts index f7e7a407f..60695f544 100644 --- a/packages/types/chain.ts +++ b/packages/types/chain.ts @@ -256,7 +256,7 @@ export type CodeIdConfig = { CwTokenfactoryIssuerMain: number CwVesting: number DaoCore: number - DaoMigrator: number + DaoMigrator?: number DaoPreProposeApprovalSingle: number DaoPreProposeApprover: number DaoPreProposeMultiple: number diff --git a/packages/types/clients/proposal-module.ts b/packages/types/clients/proposal-module.ts index bece2cc3e..fb05aeb91 100644 --- a/packages/types/clients/proposal-module.ts +++ b/packages/types/clients/proposal-module.ts @@ -1,17 +1,19 @@ import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate' import { FetchQueryOptions } from '@tanstack/react-query' -import { Coin } from '../contracts/common' -import { PreProposeModule, ProposalModule } from '../dao' +import { CheckedDepositInfo, Coin, Duration } from '../contracts/common' +import { PreProposeModule, ProposalModuleInfo } from '../dao' import { ContractVersion } from '../features' import { IDaoBase } from './dao' export interface IProposalModuleBase< Dao extends IDaoBase = IDaoBase, Proposal = any, + ProposalResponse = any, VoteResponse = any, VoteInfo = any, - Vote = any + Vote = any, + Config = any > { /** * DAO this module belongs to. @@ -21,7 +23,7 @@ export interface IProposalModuleBase< /** * Proposal module info. */ - info: ProposalModule + info: ProposalModuleInfo /** * Contract address. @@ -90,6 +92,18 @@ export interface IProposalModuleBase< sender: string }): Promise + /** + * Query options to fetch a proposal. + */ + getProposalQuery(options: { + proposalId: number + }): FetchQueryOptions + + /** + * Fetch a proposal. + */ + getProposal(options: { proposalId: number }): Promise + /** * Query options to fetch the vote on a proposal by a given address. If voter * is undefined, will return query in loading state. @@ -117,4 +131,22 @@ export interface IProposalModuleBase< * Fetch the total number of proposals. */ getProposalCount(): Promise + + /** + * Query options to fetch the config. + */ + getConfigQuery(): Pick, 'queryKey' | 'queryFn'> + + /** + * Query options to fetch configured deposit info, if any. + */ + getDepositInfoQuery(): Pick< + FetchQueryOptions, + 'queryKey' | 'queryFn' + > + + /** + * Fetch the max voting period. + */ + getMaxVotingPeriod(): Promise } diff --git a/packages/types/clients/voting-module.ts b/packages/types/clients/voting-module.ts index dc377a5dc..f327cfd94 100644 --- a/packages/types/clients/voting-module.ts +++ b/packages/types/clients/voting-module.ts @@ -5,6 +5,7 @@ import { VotingPowerAtHeightResponse, } from '../contracts/DaoDaoCore' import { ContractVersion } from '../features' +import { GenericToken } from '../token' import { IDaoBase } from './dao' export interface IVotingModuleBase { @@ -60,4 +61,13 @@ export interface IVotingModuleBase { * undefined, the latest block height will be used. */ getTotalVotingPower(height?: number): Promise + + /** + * Query options to fetch the governance token used by this voting module. Not + * all voting modules have a governance token. + */ + getGovernanceTokenQuery?(): Pick< + FetchQueryOptions, + 'queryKey' | 'queryFn' + > } diff --git a/packages/types/components/DaoProviders.ts b/packages/types/components/DaoProviders.ts new file mode 100644 index 000000000..8bd1c7a8c --- /dev/null +++ b/packages/types/components/DaoProviders.ts @@ -0,0 +1,21 @@ +import { ComponentType, ReactNode } from 'react' + +import { LoaderProps } from './Loader' + +export type DaoProvidersProps = { + chainId: string + /** + * Passing an empty string will start in a loading state. + */ + coreAddress: string + children: ReactNode + /** + * Optionally override the loader with a rendered React node. Takes precedence + * over `LoaderFallback`. + */ + loaderFallback?: ReactNode + /** + * Optionally override the Loader class to be rendered with no props. + */ + LoaderFallback?: ComponentType +} diff --git a/packages/types/components/ProfileCardWrapper.ts b/packages/types/components/ProfileCardWrapper.ts index 3aff13995..ae2984011 100644 --- a/packages/types/components/ProfileCardWrapper.ts +++ b/packages/types/components/ProfileCardWrapper.ts @@ -10,4 +10,5 @@ export type ProfileCardWrapperProps = { childContainerClassName?: string compact?: boolean className?: string + tintColor?: string } diff --git a/packages/types/components/ProposalLine.ts b/packages/types/components/ProposalLine.ts index f18844015..098899a06 100644 --- a/packages/types/components/ProposalLine.ts +++ b/packages/types/components/ProposalLine.ts @@ -6,4 +6,5 @@ export type StatefulProposalLineProps = { proposalViewUrl: string onClick?: () => void isPreProposeProposal: boolean + openInNewTab?: boolean } diff --git a/packages/types/components/ProposalList.ts b/packages/types/components/ProposalList.ts index eb6721544..831740f4b 100644 --- a/packages/types/components/ProposalList.ts +++ b/packages/types/components/ProposalList.ts @@ -1,4 +1,97 @@ -export type StatefulProposalListProps = { +import { ComponentType } from 'react' + +import { DaoWithDropdownVetoableProposalList } from '../dao' +import { LinkWrapperProps } from './LinkWrapper' +import { SearchBarProps } from './SearchBar' + +type ProposalSection = { + /** + * The title of the section. + */ + title: string + /** + * The list of proposals in the section. + */ + proposals: T[] + /** + * The total number of proposals to display next to the title. This may be + * more than the number of proposals in the list due to pagination. + */ + total?: number + /** + * Whether or not the section is collapsed by default. Defaults to false. + */ + defaultCollapsed?: boolean +} + +export type ProposalListProps = { + /** + * Open proposals are shown at the top of the list. + */ + openProposals: T[] + /** + * DAOs with proposals that can be vetoed. Shown below open proposals. + */ + daosWithVetoableProposals: DaoWithDropdownVetoableProposalList[] + /** + * Proposal sections are shown below open and vetoable proposals. + */ + sections: ProposalSection[] + /** + * Optionally show an error if proposals failed to load. + */ + error?: Error + /** + * Link to create a new proposal. + */ + createNewProposalHref?: string + /** + * Whether or not there are more proposals to load. + */ + canLoadMore: boolean + /** + * Load more proposals. + */ + loadMore: () => void + /** + * Whether or not more proposals are being loaded. + */ + loadingMore: boolean + /** + * Whether or not the current wallet is a member of the DAO. + */ + isMember: boolean + /** + * DAO name. + */ + daoName: string + + ProposalLine: ComponentType + DiscordNotifierConfigureModal?: ComponentType | undefined + LinkWrapper: ComponentType + + /** + * Optionally display a search bar. + */ + searchBarProps?: SearchBarProps + /** + * Whether or not search results are showing. + */ + showingSearchResults?: boolean + /** + * Optional class name. + */ + className?: string + /** + * Optionally hide the title. + */ + hideTitle?: boolean +} + +export type StatefulProposalListProps = Pick< + ProposalListProps, + 'className' | 'hideTitle' +> & { /** * If defined, will be called when a proposal is clicked instead of navigating * to the proposal's page. @@ -8,4 +101,12 @@ export type StatefulProposalListProps = { * If true, hides vetoable proposals. Defaults to false. */ hideVetoable?: boolean + /** + * If true, only shows executable proposals. Defaults to false. + */ + onlyExecutable?: boolean + /** + * If true, hide the notifier button. Defaults to false. + */ + hideNotifier?: boolean } diff --git a/packages/types/components/ProposalVoter.ts b/packages/types/components/ProposalVoter.ts index 32985e9f9..aea3a9c6b 100644 --- a/packages/types/components/ProposalVoter.ts +++ b/packages/types/components/ProposalVoter.ts @@ -23,11 +23,6 @@ export type ProposalVoterProps = { * proposal outcome is already determined. */ proposalOpen: boolean - /** - * Whether or not the user has viewed all action pages. If they haven't, they - * can't vote. - */ - seenAllActionPages?: boolean /** * An optional class name for the container. */ diff --git a/packages/types/components/SearchBar.ts b/packages/types/components/SearchBar.ts new file mode 100644 index 000000000..77896c3fe --- /dev/null +++ b/packages/types/components/SearchBar.ts @@ -0,0 +1,13 @@ +import { ComponentPropsWithoutRef } from 'react' + +export type SearchBarProps = Omit< + ComponentPropsWithoutRef<'input'>, + 'type' | 'variant' +> & { + hideIcon?: boolean + variant?: 'sm' | 'lg' + ghost?: boolean + onIconClick?: () => void + iconClassName?: string + containerClassName?: string +} diff --git a/packages/types/components/TokenAmountDisplay.ts b/packages/types/components/TokenAmountDisplay.ts index 7f01c5610..63fbb7b31 100644 --- a/packages/types/components/TokenAmountDisplay.ts +++ b/packages/types/components/TokenAmountDisplay.ts @@ -31,7 +31,10 @@ export type TokenAmountDisplayProps = Omit< /** * If present, will add a rounded icon to the left. */ - iconUrl?: string + iconUrl?: string | null + /** + * If defined, apply a class name to the icon. + */ iconClassName?: string /** * Overlay the chain logo over the bottom right corner of the token icon and diff --git a/packages/types/components/TokenSwapStatus.ts b/packages/types/components/TokenSwapStatus.ts index 6c25d9f11..809ef6193 100644 --- a/packages/types/components/TokenSwapStatus.ts +++ b/packages/types/components/TokenSwapStatus.ts @@ -8,7 +8,7 @@ export interface TokenSwapStatusProps { amount: number decimals: number symbol: string - tokenLogoUrl?: string + tokenLogoUrl?: string | null provided: boolean } counterparty: { @@ -16,7 +16,7 @@ export interface TokenSwapStatusProps { amount: number decimals: number symbol: string - tokenLogoUrl?: string + tokenLogoUrl?: string | null provided: boolean } diff --git a/packages/types/components/index.ts b/packages/types/components/index.ts index 80cda224c..a30317d61 100644 --- a/packages/types/components/index.ts +++ b/packages/types/components/index.ts @@ -15,6 +15,7 @@ export * from './DaoDappTabbedHome' export * from './DaoDropdown' export * from './DaoInfoCards' export * from './DaoMemberCard' +export * from './DaoProviders' export * from './DaoSdaWrappedTab' export * from './DaoSplashHeader' export * from './DaoVotingVaultCard' @@ -49,6 +50,7 @@ export * from './ProposalVoter' export * from './Row' export * from './SdaLayout' export * from './SdaNavigation' +export * from './SearchBar' export * from './SegmentedControls' export * from './SelfRelayExecuteModal' export * from './StakingModal' diff --git a/packages/types/contracts/Cw20Stake.ts b/packages/types/contracts/Cw20Stake.ts index 7da5c9acb..dbea4498f 100644 --- a/packages/types/contracts/Cw20Stake.ts +++ b/packages/types/contracts/Cw20Stake.ts @@ -1,29 +1,20 @@ -import { Addr, Binary, Duration, Expiration, Uint128 } from './common' +/** + * This file was automatically generated by @cosmwasm/ts-codegen@1.10.0. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run the @cosmwasm/ts-codegen generate command to regenerate this file. + */ -export interface AllAccountsResponse { - accounts: string[] -} -export interface AllAllowancesResponse { - allowances: AllowanceInfo[] -} -export interface AllowanceInfo { - allowance: Uint128 - expires: Expiration - spender: string -} -export interface AllowanceResponse { - allowance: Uint128 - expires: Expiration -} -export interface BalanceResponse { - balance: Uint128 -} -export interface ClaimsResponse { - claims: Claim[] -} -export interface Claim { - amount: Uint128 - release_at: Expiration +export type Duration = + | { + height: number + } + | { + time: number + } +export interface InstantiateMsg { + owner?: string | null + token_address: string + unstaking_duration?: Duration | null } export type ExecuteMsg = | { @@ -40,8 +31,6 @@ export type ExecuteMsg = | { update_config: { duration?: Duration | null - manager?: string | null - owner?: string | null } } | { @@ -54,33 +43,37 @@ export type ExecuteMsg = addr: string } } + | { + update_ownership: Action + } +export type Uint128 = string +export type Binary = string +export type Action = + | { + transfer_ownership: { + expiry?: Expiration | null + new_owner: string + } + } + | 'accept_ownership' + | 'renounce_ownership' +export type Expiration = + | { + at_height: number + } + | { + at_time: Timestamp + } + | { + never: {} + } +export type Timestamp = Uint64 +export type Uint64 = string export interface Cw20ReceiveMsg { amount: Uint128 msg: Binary sender: string } -export interface GetConfigResponse { - manager?: Addr | null - owner?: Addr | null - token_address: Addr - unstaking_duration?: Duration | null -} -export interface GetHooksResponse { - hooks: string[] -} -export interface InstantiateMsg { - manager?: string | null - owner?: string | null - token_address: string - unstaking_duration?: Duration | null -} -export interface ListStakersResponse { - stakers: StakerBalanceResponse[] -} -export interface StakerBalanceResponse { - address: string - balance: Uint128 -} export type QueryMsg = | { staked_balance_at_height: { @@ -118,6 +111,39 @@ export type QueryMsg = start_after?: string | null } } + | { + ownership: {} + } +export type MigrateMsg = { + from_v1: {} +} +export interface ClaimsResponse { + claims: Claim[] +} +export interface Claim { + amount: Uint128 + release_at: Expiration +} +export type Addr = string +export interface Config { + token_address: Addr + unstaking_duration?: Duration | null +} +export interface GetHooksResponse { + hooks: string[] +} +export interface ListStakersResponse { + stakers: StakerBalanceResponse[] +} +export interface StakerBalanceResponse { + address: string + balance: Uint128 +} +export interface OwnershipForAddr { + owner?: Addr | null + pending_expiry?: Expiration | null + pending_owner?: Addr | null +} export interface StakedBalanceAtHeightResponse { balance: Uint128 height: number @@ -125,12 +151,6 @@ export interface StakedBalanceAtHeightResponse { export interface StakedValueResponse { value: Uint128 } -export interface TokenInfoResponse { - decimals: number - name: string - symbol: string - total_supply: Uint128 -} export interface TotalStakedAtHeightResponse { height: number total: Uint128 diff --git a/packages/types/contracts/DaoDaoCore.ts b/packages/types/contracts/DaoDaoCore.ts index 527a7ab17..36afc606f 100644 --- a/packages/types/contracts/DaoDaoCore.ts +++ b/packages/types/contracts/DaoDaoCore.ts @@ -528,7 +528,7 @@ export interface VotingPowerAtHeightResponse { // Custom export type ProposalModuleWithInfo = ProposalModule & { - info?: ContractVersionInfo + info: ContractVersionInfo } export type SubDaoWithChainId = SubDao & { diff --git a/packages/types/contracts/NeutronCwdSubdaoTimelockSingle.ts b/packages/types/contracts/NeutronCwdSubdaoTimelockSingle.ts index d8235b63b..f90612768 100644 --- a/packages/types/contracts/NeutronCwdSubdaoTimelockSingle.ts +++ b/packages/types/contracts/NeutronCwdSubdaoTimelockSingle.ts @@ -1,38 +1,33 @@ /** - * This file was automatically generated by @cosmwasm/ts-codegen@0.35.7. + * This file was automatically generated by @cosmwasm/ts-codegen@1.10.0. * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, * and run the @cosmwasm/ts-codegen generate command to regenerate this file. */ export interface InstantiateMsg { overrule_pre_propose: string - [k: string]: unknown } export type ExecuteMsg = | { timelock_proposal: { msgs: CosmosMsgForNeutronMsg[] proposal_id: number - [k: string]: unknown } } | { execute_proposal: { proposal_id: number - [k: string]: unknown } } | { overrule_proposal: { proposal_id: number - [k: string]: unknown } } | { update_config: { overrule_pre_propose?: string | null owner?: string | null - [k: string]: unknown } } export type CosmosMsgForNeutronMsg = @@ -52,7 +47,6 @@ export type CosmosMsgForNeutronMsg = stargate: { type_url: string value: Binary - [k: string]: unknown } } | { @@ -69,13 +63,11 @@ export type BankMsg = send: { amount: Coin[] to_address: string - [k: string]: unknown } } | { burn: { amount: Coin[] - [k: string]: unknown } } export type Uint128 = string @@ -84,7 +76,7 @@ export type NeutronMsg = register_interchain_account: { connection_id: string interchain_account_id: string - [k: string]: unknown + register_fee?: Coin[] | null } } | { @@ -95,7 +87,6 @@ export type NeutronMsg = memo: string msgs: ProtobufAny[] timeout: number - [k: string]: unknown } } | { @@ -105,7 +96,6 @@ export type NeutronMsg = query_type: string transactions_filter: string update_period: number - [k: string]: unknown } } | { @@ -114,13 +104,11 @@ export type NeutronMsg = new_transactions_filter?: string | null new_update_period?: number | null query_id: number - [k: string]: unknown } } | { remove_interchain_query: { query_id: number - [k: string]: unknown } } | { @@ -134,26 +122,22 @@ export type NeutronMsg = timeout_height: RequestPacketTimeoutHeight timeout_timestamp: number token: Coin - [k: string]: unknown } } | { submit_admin_proposal: { admin_proposal: AdminProposal - [k: string]: unknown } } | { create_denom: { subdenom: string - [k: string]: unknown } } | { change_admin: { denom: string new_admin_address: string - [k: string]: unknown } } | { @@ -161,7 +145,6 @@ export type NeutronMsg = amount: Uint128 denom: string mint_to_address: string - [k: string]: unknown } } | { @@ -169,7 +152,32 @@ export type NeutronMsg = amount: Uint128 burn_from_address: string denom: string - [k: string]: unknown + } + } + | { + set_before_send_hook: { + contract_addr: string + denom: string + } + } + | { + force_transfer: { + amount: Uint128 + denom: string + transfer_from_address: string + transfer_to_address: string + } + } + | { + set_denom_metadata: { + base: string + denom_units: DenomUnit[] + description: string + display: string + name: string + symbol: string + uri: string + uri_hash: string } } | { @@ -177,31 +185,40 @@ export type NeutronMsg = msgs: MsgExecuteContract[] name: string period: number - [k: string]: unknown } } | { remove_schedule: { name: string - [k: string]: unknown } } + | { + resubmit_failure: { + failure_id: number + } + } + | { + dex: DexMsg + } export type Binary = string export type AdminProposal = | { param_change_proposal: ParamChangeProposal } | { - software_upgrade_proposal: SoftwareUpgradeProposal + upgrade_proposal: UpgradeProposal } | { - cancel_software_upgrade_proposal: CancelSoftwareUpgradeProposal + client_update_proposal: ClientUpdateProposal } | { - upgrade_proposal: UpgradeProposal + proposal_execute_message: ProposalExecuteMessage } | { - client_update_proposal: ClientUpdateProposal + software_upgrade_proposal: SoftwareUpgradeProposal + } + | { + cancel_software_upgrade_proposal: CancelSoftwareUpgradeProposal } | { pin_codes_proposal: PinCodesProposal @@ -218,19 +235,77 @@ export type AdminProposal = | { clear_admin_proposal: ClearAdminProposal } +export type DexMsg = + | { + deposit: { + amounts_a: Uint128[] + amounts_b: Uint128[] + fees: number[] + options: DepositOption[] + receiver: string + tick_indexes_a_to_b: number[] + token_a: string + token_b: string + } + } + | { + withdrawal: { + fees: number[] + receiver: string + shares_to_remove: Uint128[] + tick_indexes_a_to_b: number[] + token_a: string + token_b: string + } + } + | { + place_limit_order: { + amount_in: Uint128 + expiration_time?: number | null + max_amount_out?: Uint128 | null + order_type: LimitOrderType + receiver: string + tick_index_in_to_out: number + token_in: string + token_out: string + } + } + | { + withdraw_filled_limit_order: { + tranche_key: string + } + } + | { + cancel_limit_order: { + tranche_key: string + } + } + | { + multi_hop_swap: { + amount_in: Uint128 + exit_limit_price: PrecDec + pick_best_route: boolean + receiver: string + routes: MultiHopRoute[] + } + } +export type LimitOrderType = + | 'GOOD_TIL_CANCELLED' + | 'FILL_OR_KILL' + | 'IMMEDIATE_OR_CANCEL' + | 'JUST_IN_TIME' + | 'GOOD_TIL_TIME' export type StakingMsg = | { delegate: { amount: Coin validator: string - [k: string]: unknown } } | { undelegate: { amount: Coin validator: string - [k: string]: unknown } } | { @@ -238,20 +313,17 @@ export type StakingMsg = amount: Coin dst_validator: string src_validator: string - [k: string]: unknown } } export type DistributionMsg = | { set_withdraw_address: { address: string - [k: string]: unknown } } | { withdraw_delegator_reward: { validator: string - [k: string]: unknown } } export type IbcMsg = @@ -261,7 +333,6 @@ export type IbcMsg = channel_id: string timeout: IbcTimeout to_address: string - [k: string]: unknown } } | { @@ -269,13 +340,11 @@ export type IbcMsg = channel_id: string data: Binary timeout: IbcTimeout - [k: string]: unknown } } | { close_channel: { channel_id: string - [k: string]: unknown } } export type Timestamp = Uint64 @@ -286,7 +355,6 @@ export type WasmMsg = contract_addr: string funds: Coin[] msg: Binary - [k: string]: unknown } } | { @@ -296,7 +364,6 @@ export type WasmMsg = funds: Coin[] label: string msg: Binary - [k: string]: unknown } } | { @@ -304,145 +371,138 @@ export type WasmMsg = contract_addr: string msg: Binary new_code_id: number - [k: string]: unknown } } | { update_admin: { admin: string contract_addr: string - [k: string]: unknown } } | { clear_admin: { contract_addr: string - [k: string]: unknown } } export type GovMsg = { vote: { proposal_id: number vote: VoteOption - [k: string]: unknown } } export type VoteOption = 'yes' | 'no' | 'abstain' | 'no_with_veto' export interface Coin { amount: Uint128 denom: string - [k: string]: unknown } export interface IbcFee { ack_fee: Coin[] recv_fee: Coin[] timeout_fee: Coin[] - [k: string]: unknown } export interface ProtobufAny { type_url: string value: Binary - [k: string]: unknown } export interface KVKey { key: Binary path: string - [k: string]: unknown } export interface RequestPacketTimeoutHeight { revision_height?: number | null revision_number?: number | null - [k: string]: unknown } export interface ParamChangeProposal { description: string param_changes: ParamChange[] title: string - [k: string]: unknown } export interface ParamChange { key: string subspace: string value: string - [k: string]: unknown } -export interface SoftwareUpgradeProposal { +export interface UpgradeProposal { description: string plan: Plan title: string - [k: string]: unknown + upgraded_client_state: ProtobufAny } export interface Plan { height: number info: string name: string - [k: string]: unknown } -export interface CancelSoftwareUpgradeProposal { +export interface ClientUpdateProposal { description: string + subject_client_id: string + substitute_client_id: string title: string - [k: string]: unknown } -export interface UpgradeProposal { +export interface ProposalExecuteMessage { + message: string +} +export interface SoftwareUpgradeProposal { description: string plan: Plan title: string - upgraded_client_state: ProtobufAny - [k: string]: unknown } -export interface ClientUpdateProposal { +export interface CancelSoftwareUpgradeProposal { description: string - subject_client_id: string - substitute_client_id: string title: string - [k: string]: unknown } export interface PinCodesProposal { code_ids: number[] description: string title: string - [k: string]: unknown } export interface UnpinCodesProposal { code_ids: number[] description: string title: string - [k: string]: unknown } export interface SudoContractProposal { contract: string description: string msg: Binary title: string - [k: string]: unknown } export interface UpdateAdminProposal { contract: string description: string new_admin: string title: string - [k: string]: unknown } export interface ClearAdminProposal { contract: string description: string title: string - [k: string]: unknown +} +export interface DenomUnit { + aliases: string[] + denom: string + exponent: number } export interface MsgExecuteContract { contract: string msg: string - [k: string]: unknown +} +export interface DepositOption { + disable_swap: boolean +} +export interface PrecDec { + i: string +} +export interface MultiHopRoute { + hops: string[] } export interface IbcTimeout { block?: IbcTimeoutBlock | null timestamp?: Timestamp | null - [k: string]: unknown } export interface IbcTimeoutBlock { height: number revision: number - [k: string]: unknown } export type QueryMsg = | { @@ -464,9 +524,7 @@ export type QueryMsg = proposal_id: number } } -export interface MigrateMsg { - [k: string]: unknown -} +export interface MigrateMsg {} export type Addr = string export interface Config { overrule_pre_propose: Addr @@ -480,12 +538,10 @@ export type ProposalStatus = | 'execution_failed' export interface ProposalListResponse { proposals: SingleChoiceProposal[] - [k: string]: unknown } export interface SingleChoiceProposal { id: number msgs: CosmosMsgForNeutronMsg[] status: ProposalStatus - [k: string]: unknown } export type NullableString = string | null diff --git a/packages/types/contracts/OraichainCw20Staking.ts b/packages/types/contracts/OraichainCw20Staking.ts index 38a935329..3ef0d2b87 100644 --- a/packages/types/contracts/OraichainCw20Staking.ts +++ b/packages/types/contracts/OraichainCw20Staking.ts @@ -1,5 +1,5 @@ /** - * This file was automatically generated by @cosmwasm/ts-codegen@0.35.7. + * This file was automatically generated by @cosmwasm/ts-codegen@1.10.0. * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, * and run the @cosmwasm/ts-codegen generate command to regenerate this file. */ diff --git a/packages/types/contracts/ValenceAccount.ts b/packages/types/contracts/ValenceAccount.ts index 6115b34a5..2dbdc67e1 100644 --- a/packages/types/contracts/ValenceAccount.ts +++ b/packages/types/contracts/ValenceAccount.ts @@ -79,7 +79,6 @@ export type CosmosMsgForEmpty = stargate: { type_url: string value: Binary - [k: string]: unknown } } | { @@ -96,13 +95,11 @@ export type BankMsg = send: { amount: Coin[] to_address: string - [k: string]: unknown } } | { burn: { amount: Coin[] - [k: string]: unknown } } export type Uint128 = string @@ -113,7 +110,6 @@ export type IbcMsg = channel_id: string timeout: IbcTimeout to_address: string - [k: string]: unknown } } | { @@ -121,13 +117,11 @@ export type IbcMsg = channel_id: string data: Binary timeout: IbcTimeout - [k: string]: unknown } } | { close_channel: { channel_id: string - [k: string]: unknown } } export type Timestamp = Uint64 @@ -138,7 +132,6 @@ export type WasmMsg = contract_addr: string funds: Coin[] msg: Binary - [k: string]: unknown } } | { @@ -148,7 +141,6 @@ export type WasmMsg = funds: Coin[] label: string msg: Binary - [k: string]: unknown } } | { @@ -156,27 +148,23 @@ export type WasmMsg = contract_addr: string msg: Binary new_code_id: number - [k: string]: unknown } } | { update_admin: { admin: string contract_addr: string - [k: string]: unknown } } | { clear_admin: { contract_addr: string - [k: string]: unknown } } export type GovMsg = { vote: { proposal_id: number vote: VoteOption - [k: string]: unknown } } export type VoteOption = 'yes' | 'no' | 'abstain' | 'no_with_veto' @@ -193,20 +181,15 @@ export type Expiration = export interface Coin { amount: Uint128 denom: string - [k: string]: unknown -} -export interface Empty { - [k: string]: unknown } +export interface Empty {} export interface IbcTimeout { block?: IbcTimeoutBlock | null timestamp?: Timestamp | null - [k: string]: unknown } export interface IbcTimeoutBlock { height: number revision: number - [k: string]: unknown } export type QueryMsg = 'get_admin' export type Addr = string diff --git a/packages/types/dao.ts b/packages/types/dao.ts index 504647fca..c8be6f30b 100644 --- a/packages/types/dao.ts +++ b/packages/types/dao.ts @@ -67,7 +67,7 @@ export type DaoInfo = { supportedFeatures: SupportedFeatureMap votingModuleAddress: string votingModuleInfo: ContractVersionInfo - proposalModules: ProposalModule[] + proposalModules: ProposalModuleInfo[] /** * Wasm contract-level admin that can migrate. */ @@ -202,7 +202,7 @@ export type ProposalModuleTypedConfig = config?: undefined } -export type ProposalModule = { +export type ProposalModuleInfo = { contractName: string version: ContractVersion address: string diff --git a/packages/types/discord.ts b/packages/types/discord.ts new file mode 100644 index 000000000..045c03c01 --- /dev/null +++ b/packages/types/discord.ts @@ -0,0 +1,12 @@ +export type DiscordNotifierRegistration = { + id: string + guild: { + id: string + name: string + iconHash: string + } + channel: { + id: string + name: string + } +} diff --git a/packages/types/gov.ts b/packages/types/gov.ts index 353ef6c96..c5b5c9d6e 100644 --- a/packages/types/gov.ts +++ b/packages/types/gov.ts @@ -1,6 +1,6 @@ import { NestedActionsEditorFormData } from './actions' import { Coin, UnifiedCosmosMsg } from './contracts' -import { LoadingData } from './misc' +import { LoadingDataWithError } from './misc' import { ProcessedTQ, ProposalTimestampInfo } from './proposal' import { CommunityPoolSpendProposal } from './protobuf/codegen/cosmos/distribution/v1beta1/distribution' import { @@ -79,7 +79,7 @@ export type GovProposalWithDecodedContent = export type GovProposalWithMetadata = GovProposalWithDecodedContent & { timestampInfo: ProposalTimestampInfo votesInfo: GovProposalVotesInfo - walletVoteInfo: LoadingData + walletVoteInfo: LoadingDataWithError // Deposit needed to ender voting period. minDeposit: Coin[] } @@ -114,7 +114,7 @@ export type GovProposalVotesInfo = { export type GovProposalWalletVoteInfo = { // Present if voted. - vote: WeightedVoteOption[] | undefined + vote: WeightedVoteOption[] | null } export type GovProposalActionDisplayProps = { @@ -131,7 +131,12 @@ export type AllGovParams = Pick< threshold: number vetoThreshold: number minInitialDepositRatio: number -} & Partial> +} & Partial> & { + /** + * Whether or not the v1 gov module is supported on the chain. + */ + supportsV1: boolean + } export const GOVERNANCE_PROPOSAL_TYPES = [ TextProposal, @@ -145,8 +150,6 @@ export const GOVERNANCE_PROPOSAL_TYPE_CUSTOM = 'CUSTOM' export type GovernanceProposalActionData = { chainId: string - // The address of the chain's gov module. Loaded in the background. - govModuleAddress?: string version: GovProposalVersion title: string description: string diff --git a/packages/types/index.ts b/packages/types/index.ts index 24be76aa3..f49df82ea 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -7,6 +7,7 @@ export * from './components' export * from './contracts' export * from './creators' export * from './dao' +export * from './discord' export * from './features' export * from './feed' export * from './gov' diff --git a/packages/types/proposal-module-adapter.ts b/packages/types/proposal-module-adapter.ts index 722a111dc..78c48f6e3 100644 --- a/packages/types/proposal-module-adapter.ts +++ b/packages/types/proposal-module-adapter.ts @@ -4,12 +4,11 @@ import { CSSProperties, ComponentType, ReactNode } from 'react' import { FieldPath, FieldValues } from 'react-hook-form' import { RecoilValueReadOnly } from 'recoil' -import { Action, ActionCategoryMaker, ActionMaker } from './actions' +import { ActionMaker } from './actions' import { IProposalModuleBase } from './clients' import { DaoInfoCard, LinkWrapperProps, - ProposalVoterProps, SelfRelayExecuteModalProps, } from './components' import { Expiration } from './contracts' @@ -25,7 +24,7 @@ import { DaoCreationVotingConfigItem, PreProposeModule, ProposalDraft, - ProposalModule, + ProposalModuleInfo, } from './dao' import { ContractVersion } from './features' import { LoadingData } from './misc' @@ -39,10 +38,8 @@ export type IProposalModuleAdapterCommon = { makeDefaultNewProposalForm: () => FormData newProposalFormTitleKey: FieldPath - updateConfigActionMaker: ActionMaker - updatePreProposeConfigActionMaker?: ActionMaker - // Any extra actions added by the proposal module. - actionCategoryMakers?: ActionCategoryMaker[] + updateConfigActionMaker: ActionMaker + updatePreProposeConfigActionMaker?: ActionMaker } // Selectors @@ -163,7 +160,7 @@ export type IProposalModuleAdapterOptions = { /** * The proposal module. */ - proposalModule: ProposalModule + proposalModule: ProposalModuleInfo /** * The proposal ID unique across all proposal modules. They include the * proposal module's prefix, the proposal number within the proposal module, @@ -256,7 +253,7 @@ export type CommonProposalListInfo = { id: string proposalNumber: number timestamp: Date | undefined - isOpen: boolean + status: ProposalStatus // If true, will be not be shown in the proposal list. This is used for // example to hide completed pre-propose proposals that were approved, since // those show up as normal proposals. No need to double count. @@ -290,7 +287,7 @@ export type BaseProposalStatusAndInfoProps = { export type BaseProposalVoterProps = { onVoteSuccess: () => void | Promise -} & Pick +} export type BaseProposalVotesProps = { /** @@ -310,9 +307,6 @@ export type BaseProposalInnerContentDisplayProps< // Once proposal messages are loaded, the inner component is responsible for // setting the duplicate form data for the duplicate button in the header. setDuplicateFormData?: (data: FormData) => void - actionsForMatching: Action[] - // Called when the user has viewed all action pages. - setSeenAllActionPages?: () => void } export type BasePreProposeApprovalInnerContentDisplayProps = @@ -326,6 +320,7 @@ export type BaseProposalWalletVoteProps = { export type BaseProposalLineProps = { href: string onClick?: () => void + openInNewTab?: boolean LinkWrapper: ComponentType } diff --git a/packages/types/token.ts b/packages/types/token.ts index c36a628e4..a5374016d 100644 --- a/packages/types/token.ts +++ b/packages/types/token.ts @@ -65,7 +65,7 @@ export type GenericToken = { /** * The image URL for this token. */ - imageUrl: string | undefined + imageUrl?: string | null /** * The source chain and base denom. For IBC assets, this should differ from * the main fields. If the source chain ID is the same as the main chain ID, diff --git a/packages/types/voting-module-adapter.ts b/packages/types/voting-module-adapter.ts index 5ec724702..9c322a894 100644 --- a/packages/types/voting-module-adapter.ts +++ b/packages/types/voting-module-adapter.ts @@ -1,9 +1,9 @@ import { ComponentType } from 'react' -import { ActionCategoryMaker } from './actions' +import { ActionCategoryMaker, ImplementedAction } from './actions' +import { IVotingModuleBase } from './clients' import { DaoInfoCard, StakingMode } from './components' import { DaoTabWithComponent } from './dao' -import { GenericToken } from './token' export interface BaseProfileCardMemberInfoProps { maxGovernanceTokenDeposit: string | undefined @@ -28,7 +28,6 @@ export interface IVotingModuleAdapter { hooks: { useMainDaoInfoCards: () => DaoInfoCard[] useVotingModuleRelevantAddresses: () => VotingModuleRelevantAddress[] - useCommonGovernanceTokenInfo?: () => GenericToken } // Components @@ -43,7 +42,10 @@ export interface IVotingModuleAdapter { // Fields fields: { - actionCategoryMakers: ActionCategoryMaker[] + actions?: { + actions?: ImplementedAction[] + categoryMakers: ActionCategoryMaker[] + } } } @@ -64,4 +66,5 @@ export interface IVotingModuleAdapterContext { id: string options: IVotingModuleAdapterOptions adapter: IVotingModuleAdapter + votingModule: IVotingModuleBase } diff --git a/packages/types/widgets.ts b/packages/types/widgets.ts index 2071c9aa1..7b00ea219 100644 --- a/packages/types/widgets.ts +++ b/packages/types/widgets.ts @@ -5,7 +5,9 @@ import { Account } from './account' import { ActionCategoryMaker, ActionComponentProps, + ActionMaker, ActionOptions, + ImplementedAction, } from './actions' export enum WidgetId { @@ -75,7 +77,11 @@ export type Widget = any> = { // Component that allows the user to edit the widget's variables in an action. Editor?: ComponentType> // Actions that are available in proposals when this widget is enabled. - getActionCategoryMakers?: (variables: Variables) => ActionCategoryMaker[] + getActions?: (variables: Variables) => { + actions?: ImplementedAction[] + actionMakers?: ActionMaker[] + categoryMakers: ActionCategoryMaker[] + } } // DaoWidget is the structure of a widget as stored in the DAO's core item map diff --git a/packages/utils/actions.ts b/packages/utils/actions.ts index 104cc7620..59acbaade 100644 --- a/packages/utils/actions.ts +++ b/packages/utils/actions.ts @@ -1,64 +1,92 @@ import { + ActionAndData, ActionContextType, + ActionEncodeContext, ActionKeyAndData, + ActionMap, ActionOptions, - LoadedActions, UnifiedCosmosMsg, } from '@dao-dao/types' import { getAccountAddress } from './dao' -// Convert action data to a Cosmos message given all loaded actions. -export const convertActionsToMessages = ( - loadedActions: LoadedActions, - actions: ActionKeyAndData[], - { - // Whether or not to throw the error if a transform fails. If false, the - // error will be logged to the console, and the message will be skipped. - throwErrors = true, - }: { +/** + * Encode actions. + */ +export const encodeActions = async ({ + actionMap, + encodeContext, + data, + options: { throwErrors = true } = {}, +}: { + actionMap: ActionMap + encodeContext: ActionEncodeContext + data: ActionKeyAndData[] + options?: { + /** + * Whether or not to throw the error if a transform fails. If false, the + * error will be logged to the console, and the message will be skipped. + * + * Defaults to true. + */ throwErrors?: boolean - } = {} -): UnifiedCosmosMsg[] => - actions - .map(({ actionKey, data }) => { - // If no action, skip it. - if (!actionKey) { - return - } - - // If no data, throw error because this is invalidly selected. - if (!data) { - if (throwErrors) { - throw new Error('No action selected.') + } +}): Promise => + ( + await Promise.all( + data.map(async ({ actionKey, data }) => { + // If no action, skip it. + if (!actionKey) { + return [] } - return - } + // If no data, maybe throw error because this is invalidly selected. + if (!data) { + if (throwErrors) { + throw new Error('No action selected.') + } - try { - const loadedAction = loadedActions[actionKey] - if (!loadedAction) { - return - } - // If action not loaded or errored, throw error. - if (!loadedAction.defaults) { - throw new Error(`Action not loaded: ${loadedAction.action.label}.`) - } else if (loadedAction.defaults instanceof Error) { - throw loadedAction.defaults + return [] } - return loadedAction.transform(data) - } catch (err) { - if (throwErrors) { - throw err + try { + const action = actionMap[actionKey] + if (!action) { + return [] + } + + await action.init() + + return await action.encode(data, encodeContext) + } catch (err) { + if (throwErrors) { + throw err + } + + console.error(err) } - console.error(err) - } - }) - // Filter out undefined messages. - .filter(Boolean) as UnifiedCosmosMsg[] + return [] + }) + ) + ).flat() + +/** + * Resolve action keys with data to their actions. + */ +export const convertActionKeysAndDataToActions = ( + actionMap: ActionMap, + actionKeysAndData: ActionKeyAndData[] +): ActionAndData[] => + actionKeysAndData.flatMap(({ actionKey, data }) => { + const action = actionMap[actionKey] + return action + ? { + action, + data, + } + : [] + }) /** * Get the address for the given action options for the given chain. If a DAO, diff --git a/packages/utils/chain.ts b/packages/utils/chain.ts index 9924ab3bc..6e0807406 100644 --- a/packages/utils/chain.ts +++ b/packages/utils/chain.ts @@ -2,6 +2,7 @@ import { Buffer } from 'buffer' import { AssetList, Chain, IBCInfo } from '@chain-registry/types' import { fromBech32, fromHex, toBech32 } from '@cosmjs/encoding' +import uniq from 'lodash.uniq' import RIPEMD160 from 'ripemd160' import semverGte from 'semver/functions/gte' @@ -498,6 +499,39 @@ export const getIbcTransferInfoFromChannel = ( } } +/** + * Get the chain IDs with an existing IBC transfer channel to the given chain. + */ +export const getIbcTransferChainIdsForChain = (chainId: string): string[] => { + const sourceChain = getChainForChainId(chainId) + return uniq( + ibc + .filter( + ({ chain_1, chain_2, channels }) => + // Either chain is the source chain. + (chain_1.chain_name === sourceChain.chain_name || + chain_2.chain_name === sourceChain.chain_name) && + // Both chains exist in the registry. + maybeGetChainForChainName(chain_1.chain_name) && + maybeGetChainForChainName(chain_2.chain_name) && + // An ics20 transfer channel exists. + channels.some( + ({ chain_1, chain_2, version }) => + version === 'ics20-1' && + chain_1.port_id === 'transfer' && + chain_2.port_id === 'transfer' + ) + ) + .map(({ chain_1, chain_2 }) => { + const otherChain = + chain_1.chain_name === sourceChain.chain_name ? chain_2 : chain_1 + return getChainForChainName(otherChain.chain_name).chain_id + }) + // Remove nonexistent osmosis testnet chain. + .filter((chainId) => chainId !== 'osmo-test-4') + ) +} + export const getConfiguredChainConfig = ( chainId: string ): BaseChainConfig | undefined => diff --git a/packages/utils/constants/env.ts b/packages/utils/constants/env.ts index 85d44435f..b10c822c8 100644 --- a/packages/utils/constants/env.ts +++ b/packages/utils/constants/env.ts @@ -57,10 +57,6 @@ export const FILEBASE_BUCKET = process.env.FILEBASE_BUCKET as string export const FAST_AVERAGE_COLOR_API_TEMPLATE = process.env .NEXT_PUBLIC_FAST_AVERAGE_COLOR_API_TEMPLATE as string -export const DISABLED_ACTIONS = ( - process.env.NEXT_PUBLIC_DISABLED_ACTIONS || '' -).split(',') - // Discord notifier (https://github.com/DA0-DA0/discord-notifier-cf-worker) export const DISCORD_NOTIFIER_CLIENT_ID = process.env .NEXT_PUBLIC_DISCORD_NOTIFIER_CLIENT_ID as string diff --git a/packages/utils/constants/other.ts b/packages/utils/constants/other.ts index e7e2d8d2c..67748b6bf 100644 --- a/packages/utils/constants/other.ts +++ b/packages/utils/constants/other.ts @@ -66,14 +66,16 @@ export const NFT_VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi'] // The namespace (prefix) of widgets stored in a DAO's core items list. export const DAO_WIDGET_ITEM_NAMESPACE = 'widget:' -// The namespace (prefix) of cw721 contracts stored in a DAO's core items list. -// This workaround is necessary for contracts that don't conform to the expected -// contract info response. -export const CW721_WORKAROUND_ITEM_KEY_PREFIX = 'cw721:' +// The namespace (prefix) of cw721 contracts stored in a DAO's core items list +// that are to be displayed in the treasury. +export const CW721_ITEM_KEY_PREFIX = 'cw721:' // The namespace (prefix) of cw721 contracts for polytone accounts stored in a // DAO's core items list. Polytone proxies cannot register cw721s like the DAO // core contract can, so we need to store this separately. export const POLYTONE_CW721_ITEM_KEY_PREFIX = 'polytone_cw721:' +// The namespace (prefix) of cw20 contracts stored in a DAO's core items list +// that are to be displayed in the treasury. +export const CW20_ITEM_KEY_PREFIX = 'cw20:' // The namespace (prefix) of cw20 contracts for polytone accounts stored in a // DAO's core items list. Polytone proxies cannot register cw20s like the DAO // core contract can, so we need to store this separately. diff --git a/packages/utils/dao.ts b/packages/utils/dao.ts index 08e4404db..30365a996 100644 --- a/packages/utils/dao.ts +++ b/packages/utils/dao.ts @@ -69,7 +69,7 @@ export const getFundsFromDaoInstantiateMsg = ({ export const getAccount = ({ accounts, chainId, - types = [AccountType.Native, AccountType.Polytone], + types = [AccountType.Base, AccountType.Polytone], }: { accounts: readonly Account[] chainId?: string diff --git a/packages/utils/error.ts b/packages/utils/error.ts index f36813f10..568d97e9b 100644 --- a/packages/utils/error.ts +++ b/packages/utils/error.ts @@ -1,5 +1,28 @@ import * as Sentry from '@sentry/nextjs' +import { + INVALID_CONTRACT_ERROR_SUBSTRINGS, + NONEXISTENT_QUERY_ERROR_SUBSTRINGS, +} from './constants' + +/** + * Whether or not an error is a non-existent query error. + */ +export const isNonexistentQueryError = (error: unknown): boolean => + error instanceof Error && + NONEXISTENT_QUERY_ERROR_SUBSTRINGS.some((substring) => + (error as Error).message.includes(substring) + ) + +/** + * Whether or not an error is an invalid contract error. + */ +export const isInvalidContractError = (error: unknown): boolean => + error instanceof Error && + INVALID_CONTRACT_ERROR_SUBSTRINGS.some((substring) => + (error as Error).message.includes(substring) + ) + // Passing a map will allow common errors to be mapped to a custom error message // for the given context. export const processError = ( diff --git a/packages/utils/messages/cw.ts b/packages/utils/messages/cw.ts index ea1b49d33..89181bafd 100644 --- a/packages/utils/messages/cw.ts +++ b/packages/utils/messages/cw.ts @@ -1,6 +1,5 @@ import { fromBase64, toBase64, toUtf8 } from '@cosmjs/encoding' import { Coin } from '@cosmjs/proto-signing' -import JSON5 from 'json5' import cloneDeep from 'lodash.clonedeep' import { v4 as uuidv4 } from 'uuid' @@ -69,7 +68,7 @@ const BINARY_WASM_TYPES: { [key: string]: boolean } = { migrate: true, } -export function isWasmMsg(msg?: UnifiedCosmosMsg): msg is { wasm: WasmMsg } { +export function isWasmMsg(msg?: any): msg is { wasm: WasmMsg } { if (msg) { return (msg as any).wasm !== undefined } @@ -100,7 +99,13 @@ function isBinaryType(msgType?: WasmMsgType): boolean { return false } -export const decodeMessage = (msg: UnifiedCosmosMsg): Record => { +/** + * Decode a Cosmos message so that we can access its contents. For wasm + * messages, this decodes the `msg` base64 string field, and for stargate + * messages, this decodes the `value` base64 string field with available + * protobufs. All other Cosmos messages are returned as-is. + */ +export const decodeMessage = (msg: any): any => { // Decode base64 wasm binary into object. if (isWasmMsg(msg)) { const msgType = getWasmMsgType(msg.wasm) @@ -134,15 +139,44 @@ export const decodeMessage = (msg: UnifiedCosmosMsg): Record => { return msg } -export const decodeMessages = ( - msgs: UnifiedCosmosMsg[] -): Record[] => msgs.map(decodeMessage) +export const decodeMessages = (msgs: any[]): any[] => msgs.map(decodeMessage) -export function decodedMessagesString(msgs: UnifiedCosmosMsg[]): string { +export function decodedMessagesString(msgs: any[]): string { const decodedMessageArray = decodeMessages(msgs) return JSON.stringify(decodedMessageArray, undefined, 2) } +/** + * Decode wasm message binary if a wasm message, returning null if not. + */ +export const decodeWasmMessage = (message: any): Record | null => { + if (!isWasmMsg(message)) { + return null + } + + const type = getWasmMsgType(message.wasm) + if (type && isBinaryType(type)) { + const base64MsgContainer = (message.wasm as any)[type] + if (base64MsgContainer && 'msg' in base64MsgContainer) { + const parsedMsg = decodeJsonFromBase64(base64MsgContainer.msg, true) + if (parsedMsg) { + return { + ...message, + wasm: { + ...message.wasm, + [type]: { + ...base64MsgContainer, + msg: parsedMsg, + }, + }, + } + } + } + } + + return null +} + /** * Make a Cosmos message that executes a smart contract, intelligently encoded * to support Secret Network if necessary. @@ -264,12 +298,10 @@ export const makeBankMessage = ( }) /** - * Convert stringified JSON object into CosmWasm-formatted Cosmos message. Used - * by the Custom action component to encode a generic JSON string. + * Convert object into CosmWasm-formatted Cosmos message. Used by the Custom + * action component to encode a generic JSON string. */ -export const convertJsonToCWCosmosMsg = (value: string): UnifiedCosmosMsg => { - let msg = JSON5.parse(value) - +export const makeCosmosMsg = (msg: any): UnifiedCosmosMsg => { // Convert the wasm message component to base64 if necessary. if ( objectMatchesStructure(msg, { @@ -320,47 +352,44 @@ export enum StakingActionType { SetWithdrawAddress = 'set_withdraw_address', } -// If the source and destination chains are different, this wraps the message in -// a polytone execution message. Otherwise, it just returns the message. If the -// chains are different but the message is undefined, this will return a message -// that just creates a Polytone account. -export const maybeMakePolytoneExecuteMessage = ( +// If the source and destination chains are different, this wraps the message(s) +// in a polytone execution message. Otherwise, it just returns the message(s). +// If the chains are different but the message is undefined, this will return a +// message that just creates a Polytone account. +export const maybeMakePolytoneExecuteMessages = ( srcChainId: string, destChainId: string, - // Allow passing no message, which just creates an account. `msg` cannot be an - // array if the chains are the same. - msg?: UnifiedCosmosMsg | UnifiedCosmosMsg[] -): UnifiedCosmosMsg => { - // If on same chain, just return the message. - if (srcChainId === destChainId && msg) { - if (Array.isArray(msg)) { - throw new Error('Cannot use an array for same-chain messages.') - } - - return msg + // Allow passing no message, which just creates an account. + msg: UnifiedCosmosMsg | UnifiedCosmosMsg[] = [] +): UnifiedCosmosMsg[] => { + // If on same chain, just return the message(s). + if (srcChainId === destChainId) { + return [msg].flat() } const polytoneConnection = getSupportedChainConfig(srcChainId)?.polytone?.[destChainId] - return makeWasmMessage({ - wasm: { - execute: { - contract_addr: polytoneConnection?.note, - funds: [], - msg: { - execute: { - msgs: msg ? [msg].flat() : [], - timeout_seconds: BigInt(IBC_TIMEOUT_SECONDS).toString(), - callback: { - msg: toBase64(toUtf8(uuidv4())), - receiver: polytoneConnection?.listener, + return [ + makeWasmMessage({ + wasm: { + execute: { + contract_addr: polytoneConnection?.note, + funds: [], + msg: { + execute: { + msgs: [msg].flat(), + timeout_seconds: BigInt(IBC_TIMEOUT_SECONDS).toString(), + callback: { + msg: toBase64(toUtf8(uuidv4())), + receiver: polytoneConnection?.listener, + }, }, }, }, }, - }, - }) + }), + ] } /** @@ -429,10 +458,10 @@ export const decodePolytoneExecuteMsg = ( } /** - * If the source and destination chains are different, this wraps the message in - * an ICA execution message. Otherwise, it just returns the message. + * If the source and destination chains are different, this wraps the message(s) + * in an ICA execution message. Otherwise, it just returns the message(s). */ -export const maybeMakeIcaExecuteMessage = ( +export const maybeMakeIcaExecuteMessages = ( srcChainId: string, destChainId: string, /** @@ -444,42 +473,40 @@ export const maybeMakeIcaExecuteMessage = ( */ icaRemoteAddress: string, msg: UnifiedCosmosMsg | UnifiedCosmosMsg[] -): UnifiedCosmosMsg => { +): UnifiedCosmosMsg[] => { // If on same chain, just return the message. if (srcChainId === destChainId && msg) { - if (Array.isArray(msg)) { - throw new Error('Cannot use an array for same-chain messages.') - } - - return msg + return [msg].flat() } const { sourceChain: { connection_id: connectionId }, } = getIbcTransferInfoBetweenChains(srcChainId, destChainId) - return makeStargateMessage({ - stargate: { - typeUrl: MsgSendTx.typeUrl, - value: MsgSendTx.fromPartial({ - owner: icaHostAddress, - connectionId, - packetData: InterchainAccountPacketData.fromPartial({ - type: Type.TYPE_EXECUTE_TX, - data: CosmosTx.toProto({ - messages: [msg] - .flat() - .map((msg) => - cwMsgToProtobuf(destChainId, msg, icaRemoteAddress) - ), + return [ + makeStargateMessage({ + stargate: { + typeUrl: MsgSendTx.typeUrl, + value: MsgSendTx.fromPartial({ + owner: icaHostAddress, + connectionId, + packetData: InterchainAccountPacketData.fromPartial({ + type: Type.TYPE_EXECUTE_TX, + data: CosmosTx.toProto({ + messages: [msg] + .flat() + .map((msg) => + cwMsgToProtobuf(destChainId, msg, icaRemoteAddress) + ), + }), + memo: '', }), - memo: '', + // Nanoseconds timeout from TX execution. + relativeTimeout: BigInt(IBC_TIMEOUT_SECONDS * 1e9), }), - // Nanoseconds timeout from TX execution. - relativeTimeout: BigInt(IBC_TIMEOUT_SECONDS * 1e9), - }), - }, - }) + }, + }), + ] } /** @@ -492,10 +519,7 @@ export const decodeIcaExecuteMsg = ( // How many messages are expected. type: 'one' | 'zero' | 'oneOrZero' | 'any' = 'one' ): DecodedIcaMsg => { - if ( - !isDecodedStargateMsg(decodedMsg) || - decodedMsg.stargate.typeUrl !== MsgSendTx.typeUrl - ) { + if (!isDecodedStargateMsg(decodedMsg, MsgSendTx)) { return { match: false, } @@ -555,10 +579,7 @@ export const decodeIcaCreateMsg = ( srcChainId: string, decodedMsg: Record ): DecodedIcaMsg => { - if ( - !isDecodedStargateMsg(decodedMsg) || - decodedMsg.stargate.typeUrl !== MsgRegisterInterchainAccount.typeUrl - ) { + if (!isDecodedStargateMsg(decodedMsg, MsgRegisterInterchainAccount)) { return { match: false, } diff --git a/packages/utils/messages/protobuf.ts b/packages/utils/messages/protobuf.ts index c9267932b..dd73d5d4b 100644 --- a/packages/utils/messages/protobuf.ts +++ b/packages/utils/messages/protobuf.ts @@ -19,7 +19,7 @@ import { Any } from '@dao-dao/types/protobuf/codegen/google/protobuf/any' import { getChainForChainId } from '../chain' import { transformIpfsUrlToHttpsIfNecessary } from '../conversion' import { isValidUrl } from '../isValidUrl' -import { objectMatchesStructure } from '../objectMatchesStructure' +import { Structure, objectMatchesStructure } from '../objectMatchesStructure' import { isCosmWasmStargateMsg } from './cw' // Decode governance proposal v1 messages using a protobuf. @@ -142,13 +142,35 @@ export const decodeGovProposal = async ( } } -export const isDecodedStargateMsg = (msg: any): msg is DecodedStargateMsg => +/** + * Check if a message is a decoded stargate message, whose value is an object + * (decoded from binary). Optionally check if it matches a specific type URL. + */ +export const isDecodedStargateMsg = ( + msg: any, + /** + * If provided, check if the message's type URL matches this type or any of + * the types if it's an array. + */ + typeOrUrl?: string | { typeUrl: string } | (string | { typeUrl: string })[], + /** + * If provided, check if the message's value matches this structure. + */ + value: Structure = {} +): msg is DecodedStargateMsg => objectMatchesStructure(msg, { stargate: { typeUrl: {}, - value: {}, + value, }, - }) && typeof msg.stargate.value === 'object' + }) && + typeof msg.stargate.value === 'object' && + (!typeOrUrl || + [typeOrUrl] + .flat() + .some( + (t) => msg.stargate.typeUrl === (typeof t === 'string' ? t : t.typeUrl) + )) /** * Decode raw JSON data for displaying. Decode any nested protobufs into JSON. diff --git a/packages/utils/objectMatchesStructure.test.ts b/packages/utils/objectMatchesStructure.test.ts index cbe81e4d4..ded198422 100644 --- a/packages/utils/objectMatchesStructure.test.ts +++ b/packages/utils/objectMatchesStructure.test.ts @@ -16,6 +16,16 @@ const object = { and_an_undefined_value: undefined, }, another: false, + array_of_objs: [ + { + a: 1, + b: 2, + }, + { + c: 3, + d: 4, + }, + ], } test('objectMatchesStructure', () => { @@ -140,4 +150,89 @@ test('objectMatchesStructure', () => { } ) ).toBe(false) + expect( + objectMatchesStructure(object, { + array_of_objs: {}, + }) + ).toBe(true) + expect( + objectMatchesStructure(object, { + array_of_objs: [{}], + }) + ).toBe(false) + expect( + objectMatchesStructure(object, { + array_of_objs: [{}, {}], + }) + ).toBe(true) + expect( + objectMatchesStructure(object, { + array_of_objs: [ + { + a: {}, + b: {}, + }, + {}, + ], + }) + ).toBe(true) + expect( + objectMatchesStructure(object, { + array_of_objs: [ + { + a: {}, + b: {}, + }, + { + d: {}, + }, + ], + }) + ).toBe(true) + expect( + objectMatchesStructure(object, { + array_of_objs: [ + { + a: {}, + b: {}, + }, + { + c: {}, + d: {}, + }, + ], + }) + ).toBe(true) + expect( + objectMatchesStructure(object, { + array_of_objs: [ + { + a: {}, + b: {}, + }, + { + c: {}, + d: {}, + e: {}, + }, + ], + }) + ).toBe(false) + expect( + objectMatchesStructure(object, { + array_of_objs: [ + { + a: {}, + b: {}, + }, + { + c: {}, + d: {}, + }, + { + e: {}, + }, + ], + }) + ).toBe(false) }) diff --git a/packages/utils/objectMatchesStructure.ts b/packages/utils/objectMatchesStructure.ts index dbcab94ff..8ead57df7 100644 --- a/packages/utils/objectMatchesStructure.ts +++ b/packages/utils/objectMatchesStructure.ts @@ -1,9 +1,15 @@ export type Structure = { // Nest to match more keys or use an empty object ({}) to check existence. - [key: string]: Structure | { [key: string | number | symbol]: never } + [key: string]: + | Structure + | Structure[] + | { [key: string | number | symbol]: never } } -// Check if object contains the expected structure. +/** + * Check if object contains the expected structure, with exact matching on array + * lengths. + */ export const objectMatchesStructure = ( object: any | undefined | null, structure: Structure, @@ -45,14 +51,27 @@ export const objectMatchesStructure = ( // Recurse, first verifying the value of the key in the object is an // object. (typeof object[topLevelKey] === 'object' && - !Array.isArray(object[topLevelKey]) && // typeof null === 'object', so verify this is not null before checking // its internal keys. object[topLevelKey] !== null && - objectMatchesStructure( - object[topLevelKey] as Record, - structureOrEmptyObject, - options - )) + // if the structure is an array, ensure the value of the key is an array + // and check each one. + ((Array.isArray(structureOrEmptyObject) && + Array.isArray(object[topLevelKey]) && + structureOrEmptyObject.length === object[topLevelKey].length && + structureOrEmptyObject.every((structure, index) => + objectMatchesStructure( + object[topLevelKey][index] as Record, + structure, + options + ) + )) || + // if the structure is not an array, recurse on the object. + (!Array.isArray(structureOrEmptyObject) && + objectMatchesStructure( + object[topLevelKey] as Record, + structureOrEmptyObject, + options + )))) ) } diff --git a/packages/utils/package.json b/packages/utils/package.json index 9acb8d0f6..9cbda0965 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -27,6 +27,7 @@ "chain-registry": "^1.59.4", "json5": "^2.2.0", "lodash.clonedeep": "^4.5.0", + "lodash.uniq": "^4.5.0", "long": "^5.2.1", "luxon": "^3.4.4", "next-i18next": "^11.0.0", @@ -44,6 +45,7 @@ "@cosmjs/proto-signing": "^0.32.3", "@dao-dao/config": "2.5.0-rc.2", "@tanstack/react-query": "^5.40.0", + "@types/lodash.uniq": "^4.5.7", "@types/luxon": "^3.4.2", "@types/pako": "^2.0.3", "commander": "^12.1.0", diff --git a/packages/utils/proposal.ts b/packages/utils/proposal.ts index 3c65cbd0a..d719c4c69 100644 --- a/packages/utils/proposal.ts +++ b/packages/utils/proposal.ts @@ -2,7 +2,7 @@ import { TFunction } from 'react-i18next' import { DurationUnits, - ProposalModule, + ProposalModuleInfo, ProposalVetoConfig, } from '@dao-dao/types' import { @@ -20,7 +20,44 @@ import { convertDurationWithUnitsToDuration, } from './conversion' -// Get the status key from the weirdly-formatted status enum. +/** + * Extract info from proposal ID. + */ +export const extractProposalInfo = ( + proposalId: string +): { + prefix: string + proposalNumber: number + isPreProposeApprovalProposal: boolean +} => { + // Prefix is alphabetical, followed by numeric prop number. If there is an + // asterisk between the prefix and the prop number, this is a pre-propose + // proposal. Allow the prefix to be empty for backwards compatibility. Default + // to first proposal module if no alphabetical prefix. + const proposalIdParts = proposalId.match(/^([A-Z]*)(\*)?(\d+)$/) + if (proposalIdParts?.length !== 4) { + throw new Error('Failed to parse proposal ID.') + } + + // Undefined if matching group doesn't exist, i.e. no prefix exists. + const prefix = proposalIdParts[1] ?? '' + const isPreProposeApprovalProposal = proposalIdParts[2] === '*' + const proposalNumber = Number(proposalIdParts[3]) + + if (isNaN(proposalNumber)) { + throw new Error(`Invalid proposal number "${proposalNumber}".`) + } + + return { + prefix, + proposalNumber, + isPreProposeApprovalProposal, + } +} + +/** + * Get the status key from the weirdly-formatted status enum. + */ export const keyFromPreProposeStatus = ( status: PreProposeStatus ): PreProposeStatusKey => Object.keys(status)[0] as PreProposeStatusKey @@ -72,8 +109,10 @@ export const convertVetoConfigToCosmos = ( */ export const convertCosmosVetoConfigToVeto = ( veto: VetoConfig | null | undefined, - // If provided, `veto.vetoer` should be a cw1-whitelist contract address, and - // this should be its list of admins. + /** + * If provided, `veto.vetoer` should be a cw1-whitelist contract address, and + * this should be its list of admins. + */ cw1WhitelistAdmins?: string[] | null ): ProposalVetoConfig => veto @@ -122,7 +161,7 @@ export const checkProposalSubmissionPolicy = ({ /** * The proposal module. */ - proposalModule: ProposalModule + proposalModule: ProposalModuleInfo /** * Current wallet address. Undefined if not connected. */ diff --git a/packages/utils/widgets.ts b/packages/utils/widgets.ts index 5c027e136..d09ae8fc7 100644 --- a/packages/utils/widgets.ts +++ b/packages/utils/widgets.ts @@ -1,7 +1,36 @@ +import { DaoWidget, WidgetId } from '@dao-dao/types' + import { DAO_WIDGET_ITEM_NAMESPACE } from './constants' +import { getFilteredDaoItemsByPrefix } from './dao' /** - * Get the DAO storage item key for a widget iD. + * Get the key in the DAO storage items map for a widget item. + * + * @param id `WidgetId` of the widget item. + * @returns The key in the DAO storage items map for the widget item. */ -export const getWidgetStorageItemKey = (id: string) => +export const getWidgetStorageItemKey = (id: WidgetId | string): string => DAO_WIDGET_ITEM_NAMESPACE + id + +/** + * Get the DAO widgets from the DAO storage items. + * + * @param items The DAO storage items. + * @returns Parsed DAO widgets. + */ +export const getDaoWidgets = (items: Record): DaoWidget[] => + getFilteredDaoItemsByPrefix(items, getWidgetStorageItemKey('')) + .map(([id, widgetJson]): DaoWidget | undefined => { + try { + return { + id, + values: (widgetJson && JSON.parse(widgetJson)) || {}, + } + } catch (err) { + // Ignore widget format error but log to console for debugging. + console.error(`Invalid widget JSON: ${widgetJson}`, err) + return + } + }) + // Validate widget structure. + .filter((widget): widget is DaoWidget => !!widget)