diff --git a/packages/i18n/locales/en/translation.json b/packages/i18n/locales/en/translation.json index ecb81852b..46daff4ec 100644 --- a/packages/i18n/locales/en/translation.json +++ b/packages/i18n/locales/en/translation.json @@ -277,6 +277,7 @@ "raisedHand": "Raised hand", "recycle": "Recycle", "robot": "Robot", + "rocketShip": "Rocket ship", "suitAndTie": "Suit and tie", "swords": "Swords", "telescope": "Telescope", @@ -332,6 +333,7 @@ "feeTokenNotFound": "Fee token not found.", "govTokenBalancesDoNotSumTo100": "Total token distribution percentage must equal 100%, but it currently sums to {{totalPercent}}.", "icaAccountAlreadyExists": "ICA account already exists on {{chain}}.", + "icaAccountDoesNotExist": "ICA account does not exist on {{chain}}.", "icaAddressNotLoaded": "ICA address not loaded.", "insufficientForDeposit": "You do not have enough funds to cover a deposit of {{amount}} ${{tokenSymbol}}.", "insufficientFunds": "Insufficient funds.", @@ -836,6 +838,7 @@ "govTokenAddress": "Governance Token", "groupAddress": "CW4 Group", "historySinceDate": "History since {{date}}", + "icaExecuteDescription": "Execute an action from an ICA account on another chain.", "inboxConfigPreferencesDescription": "Choose where you want to receive notifications. Website notifications appear here on the Inbox page.", "inboxDescription": "Your notification inbox.", "inboxEmailTooltip": "Receive inbox notifications in your email.", @@ -1297,6 +1300,7 @@ "history": "History", "holdings": "Holdings", "home": "Home", + "icaExecute": "ICA Execute", "identity": "Identity", "inbox": "Inbox", "inboxConfiguration": "Inbox configuration", diff --git a/packages/state/recoil/selectors/contract.ts b/packages/state/recoil/selectors/contract.ts index be7046e0b..be56fe67e 100644 --- a/packages/state/recoil/selectors/contract.ts +++ b/packages/state/recoil/selectors/contract.ts @@ -187,7 +187,7 @@ export const isContractSelector = selectorFamily< // If contract does not exist, not the desired contract. if ( err instanceof Error && - err.message.includes('contract: not found: invalid request') + err.message.includes('not found: invalid request') ) { console.error(err) return false diff --git a/packages/stateful/actions/core/advanced/CreateIcaAccount/index.tsx b/packages/stateful/actions/core/advanced/CreateIcaAccount/index.tsx index e758c6c0f..29edcc563 100644 --- a/packages/stateful/actions/core/advanced/CreateIcaAccount/index.tsx +++ b/packages/stateful/actions/core/advanced/CreateIcaAccount/index.tsx @@ -100,28 +100,26 @@ export const makeCreateIcaAccountAction: ActionMaker = ({ const info = getIbcTransferInfoBetweenChains(sourceChainId, chainId) - return chainId - ? 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', - }) - ), - }), - }, - }) - : undefined + 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 = ( diff --git a/packages/stateful/actions/core/advanced/IcaExecute/Component.tsx b/packages/stateful/actions/core/advanced/IcaExecute/Component.tsx new file mode 100644 index 000000000..8aa312892 --- /dev/null +++ b/packages/stateful/actions/core/advanced/IcaExecute/Component.tsx @@ -0,0 +1,43 @@ +import { useTranslation } from 'react-i18next' + +import { + NestedActionsEditor, + NestedActionsEditorOptions, + NestedActionsRenderer, + NestedActionsRendererProps, +} from '@dao-dao/stateless' +import { NestedActionsEditorFormData } from '@dao-dao/types' +import { ActionComponent } from '@dao-dao/types/actions' + +export type IcaExecuteData = { + chainId: string + // Set automatically once chain ID is chosen. + icaRemoteAddress: string +} & NestedActionsEditorFormData + +export type IcaExecuteOptions = NestedActionsEditorOptions & + Omit + +export const IcaExecuteComponent: ActionComponent = ( + props +) => { + const { t } = useTranslation() + const { fieldNamePrefix, isCreating, options } = props + + return ( + <> +

{t('title.actions')}

+ + {isCreating ? ( + + ) : ( +
+ +
+ )} + + ) +} diff --git a/packages/stateful/actions/core/advanced/IcaExecute/README.md b/packages/stateful/actions/core/advanced/IcaExecute/README.md new file mode 100644 index 000000000..4fd572e93 --- /dev/null +++ b/packages/stateful/actions/core/advanced/IcaExecute/README.md @@ -0,0 +1,29 @@ +# IcaExecute + +Execute messages from an ICA account. + +## 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 + +`icaExecute` + +### Data format + +```json +{ + "chainId": "", + "_actionData": [ + // ACTIONS + ] +} +``` + +Each action in `_actionData` should be formatted similar to how bulk actions are +formatted, with `key` and `data` fields, except `key` should be replaced with +`actionKey`. Thus, each object in `_actionData` should be an object with +`actionKey` and `data`, formatted the same as the `actions` array in the bulk +JSON format. diff --git a/packages/stateful/actions/core/advanced/IcaExecute/index.tsx b/packages/stateful/actions/core/advanced/IcaExecute/index.tsx new file mode 100644 index 000000000..32b9de017 --- /dev/null +++ b/packages/stateful/actions/core/advanced/IcaExecute/index.tsx @@ -0,0 +1,298 @@ +import { useCallback, useEffect } from 'react' +import { useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' + +import { MsgSendTx } from '@dao-dao/protobuf/codegen/ibc/applications/interchain_accounts/controller/v1/tx' +import { + CosmosTx, + InterchainAccountPacketData, + Type, +} from '@dao-dao/protobuf/codegen/ibc/applications/interchain_accounts/v1/packet' +import { icaRemoteAddressSelector } from '@dao-dao/state/recoil' +import { + ChainProvider, + CopyToClipboard, + IbcDestinationChainPicker, + InputErrorMessage, + Loader, + RocketShipEmoji, + useCachedLoadingWithError, +} from '@dao-dao/stateless' +import { + ActionComponent, + ActionKey, + ActionMaker, + UseDecodedCosmosMsg, + UseDefaults, + UseTransformToCosmos, +} from '@dao-dao/types' +import { + IBC_TIMEOUT_SECONDS, + cwMsgToProtobuf, + getChainForChainName, + getDisplayNameForChainId, + getIbcTransferInfoBetweenChains, + getIbcTransferInfoFromConnection, + isDecodedStargateMsg, + makeStargateMessage, + protobufToCwMsg, +} from '@dao-dao/utils' + +import { SuspenseLoader } from '../../../../components' +import { + WalletActionsProvider, + useActionOptions, + useActionsForMatching, + useLoadedActionsAndCategories, +} from '../../../react' +import { + IcaExecuteData, + IcaExecuteComponent as StatelessIcaExecuteComponent, +} from './Component' + +const InnerComponent: ActionComponent = (props) => { + const { categories, loadedActions } = useLoadedActionsAndCategories({ + isCreating: props.isCreating, + }) + const actionsForMatching = useActionsForMatching() + + return ( + + ) +} + +const Component: ActionComponent = (props) => { + const { t } = useTranslation() + const { + address, + context, + chain: { chain_id: srcChainId }, + } = useActionOptions() + + const { watch, setError, clearErrors, setValue } = + useFormContext() + const destChainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') + + const icaRemoteAddressLoading = useCachedLoadingWithError( + destChainId + ? icaRemoteAddressSelector({ + address, + srcChainId, + destChainId, + }) + : undefined + ) + + // Set error and ICA remote address loader. + useEffect(() => { + setValue( + (props.fieldNamePrefix + 'icaRemoteAddress') as 'icaRemoteAddress', + '' + ) + + if (icaRemoteAddressLoading.loading || icaRemoteAddressLoading.updating) { + clearErrors( + (props.fieldNamePrefix + 'icaRemoteAddress') as 'icaRemoteAddress' + ) + return + } + + if (icaRemoteAddressLoading.errored) { + setError( + (props.fieldNamePrefix + 'icaRemoteAddress') as 'icaRemoteAddress', + { + type: 'manual', + message: + icaRemoteAddressLoading.error instanceof Error + ? icaRemoteAddressLoading.error.message + : `${icaRemoteAddressLoading.error}`, + } + ) + } else if (!icaRemoteAddressLoading.data) { + setError( + (props.fieldNamePrefix + 'icaRemoteAddress') as 'icaRemoteAddress', + { + type: 'manual', + message: t('error.icaAccountDoesNotExist', { + chain: getDisplayNameForChainId(destChainId), + }), + } + ) + } else { + clearErrors( + (props.fieldNamePrefix + 'icaRemoteAddress') as 'icaRemoteAddress' + ) + setValue( + (props.fieldNamePrefix + 'icaRemoteAddress') as 'icaRemoteAddress', + icaRemoteAddressLoading.data + ) + } + }, [ + clearErrors, + context.type, + destChainId, + icaRemoteAddressLoading, + props.fieldNamePrefix, + setError, + setValue, + t, + ]) + + return ( + <> +
+ + setValue((props.fieldNamePrefix + 'chainId') as 'chainId', chainId) + } + onlySupportedChains + selectedChainId={destChainId} + sourceChainId={srcChainId} + /> + + {!icaRemoteAddressLoading.loading && + !icaRemoteAddressLoading.updating && + !icaRemoteAddressLoading.errored && + !!icaRemoteAddressLoading.data && ( + + )} +
+ + {!!destChainId && + (icaRemoteAddressLoading.loading || icaRemoteAddressLoading.updating ? ( + + ) : ( + <> + + + {!icaRemoteAddressLoading.errored && + !!icaRemoteAddressLoading.data && ( + // Re-render when chain changes so hooks and state reset. + + + + + + )} + + ))} + + ) +} + +export const makeIcaExecuteAction: ActionMaker = ({ + t, + address, + chain: { chain_id: sourceChainId }, +}) => { + const useDefaults: UseDefaults = () => ({ + chainId: '', + icaRemoteAddress: '', + msgs: [], + }) + + const useTransformToCosmos: UseTransformToCosmos = () => + useCallback(({ chainId, icaRemoteAddress, msgs }) => { + if (!chainId || !icaRemoteAddress) { + return + } + + const { + sourceChain: { connection_id: connectionId }, + } = getIbcTransferInfoBetweenChains(sourceChainId, chainId) + + return makeStargateMessage({ + stargate: { + typeUrl: MsgSendTx.typeUrl, + value: MsgSendTx.fromPartial({ + owner: address, + connectionId, + packetData: InterchainAccountPacketData.fromPartial({ + type: Type.TYPE_EXECUTE_TX, + data: CosmosTx.toProto({ + messages: msgs.map((msg) => + cwMsgToProtobuf(msg, icaRemoteAddress) + ), + }), + memo: '', + }), + // Nanoseconds timeout from TX execution. + relativeTimeout: BigInt(IBC_TIMEOUT_SECONDS * 1e9), + }), + }, + }) + }, []) + + const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( + msg: Record + ) => { + if ( + !isDecodedStargateMsg(msg) || + msg.stargate.typeUrl !== MsgSendTx.typeUrl + ) { + return { + match: false, + } + } + + try { + const { connectionId } = msg.stargate.value + const { destinationChain } = getIbcTransferInfoFromConnection( + sourceChainId, + connectionId + ) + + const { packetData: { data } = {} } = msg.stargate.value as MsgSendTx + const protobufMessages = data && CosmosTx.decode(data).messages + const msgs = + protobufMessages?.map((protobuf) => protobufToCwMsg(protobuf).msg) || [] + + return { + match: true, + data: { + chainId: getChainForChainName(destinationChain.chain_name).chain_id, + // Not needed for decoding. + icaRemoteAddress: '', + msgs, + }, + } + } catch (err) { + return { + match: false, + } + } + } + + return { + key: ActionKey.IcaExecute, + Icon: RocketShipEmoji, + label: t('title.icaExecute'), + description: t('info.icaExecuteDescription'), + Component, + useDefaults, + useTransformToCosmos, + useDecodedCosmosMsg, + } +} diff --git a/packages/stateful/actions/core/advanced/index.ts b/packages/stateful/actions/core/advanced/index.ts index 14f4639fa..919659309 100644 --- a/packages/stateful/actions/core/advanced/index.ts +++ b/packages/stateful/actions/core/advanced/index.ts @@ -4,6 +4,7 @@ import { makeBulkImportAction } from './BulkImport' import { makeCreateIcaAccountAction } from './CreateIcaAccount' import { makeCrossChainExecuteAction } from './CrossChainExecute' import { makeCustomAction } from './Custom' +import { makeIcaExecuteAction } from './IcaExecute' export const makeAdvancedActionCategory: ActionCategoryMaker = ({ t }) => ({ key: ActionCategoryKey.Custom, @@ -13,6 +14,7 @@ export const makeAdvancedActionCategory: ActionCategoryMaker = ({ t }) => ({ makeCustomAction, makeCrossChainExecuteAction, makeCreateIcaAccountAction, + makeIcaExecuteAction, makeBulkImportAction, ], }) diff --git a/packages/stateless/components/IbcDestinationChainPicker.tsx b/packages/stateless/components/IbcDestinationChainPicker.tsx index f9a675446..679429986 100644 --- a/packages/stateless/components/IbcDestinationChainPicker.tsx +++ b/packages/stateless/components/IbcDestinationChainPicker.tsx @@ -8,6 +8,7 @@ import { getChainForChainId, getChainForChainName, getImageUrlForChainId, + getSupportedChains, toAccessibleImageUrl, } from '@dao-dao/utils' @@ -18,6 +19,9 @@ export type IbcDestinationChainPickerProps = { sourceChainId: string // Whether or not to include the source chain in the list. includeSourceChain: boolean + // If defined, will filter potential destination chains by those supported by + // the DAO DAO UI. + onlySupportedChains?: boolean // The selected destination chain. selectedChainId: string // Chain selection handler. @@ -37,6 +41,7 @@ export type IbcDestinationChainPickerProps = { export const IbcDestinationChainPicker = ({ sourceChainId, includeSourceChain, + onlySupportedChains, selectedChainId, onChainSelected, disabled, @@ -44,6 +49,7 @@ export const IbcDestinationChainPicker = ({ }: IbcDestinationChainPickerProps) => { const { t } = useTranslation() const chains = useMemo(() => { + const supportedChains = onlySupportedChains ? getSupportedChains() : [] const spendChain = getChainForChainId(sourceChainId) return [ // Source chain. @@ -70,8 +76,15 @@ export const IbcDestinationChainPicker = ({ }) // Remove nonexistent osmosis testnet chain. .filter((chain) => chain.chain_id !== 'osmo-test-4'), - ] - }, [includeSourceChain, sourceChainId]) + ].filter( + (chain) => + !onlySupportedChains || + // Filter by supported chains. + supportedChains.some( + (supported) => supported.chain.chain_id === chain.chain_id + ) + ) + }, [includeSourceChain, onlySupportedChains, sourceChainId]) const selectedChain = selectedChainId ? getChainForChainId(selectedChainId) diff --git a/packages/stateless/components/emoji.tsx b/packages/stateless/components/emoji.tsx index 3f2f87274..307f6da90 100644 --- a/packages/stateless/components/emoji.tsx +++ b/packages/stateless/components/emoji.tsx @@ -240,3 +240,7 @@ export const FilmSlateEmoji = () => ( export const BalanceEmoji = () => ( ) + +export const RocketShipEmoji = () => ( + +) diff --git a/packages/stateless/components/inputs/InputErrorMessage.tsx b/packages/stateless/components/inputs/InputErrorMessage.tsx index 9b2daf7cd..822f18f1e 100644 --- a/packages/stateless/components/inputs/InputErrorMessage.tsx +++ b/packages/stateless/components/inputs/InputErrorMessage.tsx @@ -2,21 +2,31 @@ import clsx from 'clsx' import { FieldError } from 'react-hook-form' export interface InputErrorMessageProps { - error?: FieldError | string + error?: FieldError | string | Error | unknown className?: string } export const InputErrorMessage = ({ error, className, -}: InputErrorMessageProps) => - typeof error === 'string' || error?.message ? ( +}: InputErrorMessageProps) => { + const message = + error && + (typeof error === 'string' + ? error + : error instanceof Error || + (typeof error === 'object' && 'message' in error) + ? error.message + : `${error}`) + + return message ? ( - {typeof error === 'string' ? error : error.message} + {message} ) : null +} diff --git a/packages/types/actions.ts b/packages/types/actions.ts index cfc42cc95..3d3bb13f2 100644 --- a/packages/types/actions.ts +++ b/packages/types/actions.ts @@ -65,6 +65,7 @@ export enum ActionKey { CreateCrossChainAccount = 'createCrossChainAccount', CrossChainExecute = 'crossChainExecute', CreateIcaAccount = 'createIcaAccount', + IcaExecute = 'icaExecute', ConfigureRebalancer = 'configureRebalancer', // DaoProposalSingle UpdatePreProposeSingleConfig = 'updatePreProposeSingleConfig',