diff --git a/packages/i18n/locales/en/translation.json b/packages/i18n/locales/en/translation.json index da99689584..ecb81852b2 100644 --- a/packages/i18n/locales/en/translation.json +++ b/packages/i18n/locales/en/translation.json @@ -160,6 +160,7 @@ "saveDraft": "Save draft", "searchDaos": "Search DAOs", "selectAllNfts": "Select all {{count}} NFTs", + "selectChain": "Select chain", "selectNft": "Select NFT", "selectToken": "Select token", "selectValidator": "Select validator", @@ -330,6 +331,7 @@ "failedToRelayPackets": "Failed to relay packets to {{chain}}.", "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}}.", "icaAddressNotLoaded": "ICA address not loaded.", "insufficientForDeposit": "You do not have enough funds to cover a deposit of {{amount}} ${{tokenSymbol}}.", "insufficientFunds": "Insufficient funds.", @@ -765,6 +767,7 @@ "createCrossChainAccountDescription": "Create an account for this DAO on another chain.", "createCrossChainAccountExplanation": "This action creates an account on another chain, allowing this DAO to perform actions on that chain.", "createFirstOneQuestion": "Create the first one?", + "createICAAccountDescription": "Create an account with the Interchain Accounts SDK module.", "createNftCollectionDescription_dao": "Create a new NFT collection controlled by the DAO.", "createNftCollectionDescription_gov": "Create a new NFT collection controlled by the chain.", "createNftCollectionDescription_wallet": "Create a new NFT collection controlled by you.", @@ -1226,6 +1229,7 @@ "createAProposal": "Create a proposal", "createASubDao": "Create a SubDAO", "createCrossChainAccount": "Create Cross-Chain Account", + "createICAAccount": "Create ICA Account", "createNftCollection": "Create NFT Collection", "createPost": "Create Post", "createProposal": "Create proposal", diff --git a/packages/state/recoil/selectors/token.ts b/packages/state/recoil/selectors/token.ts index d2ac31d647..6c2ee82e8f 100644 --- a/packages/state/recoil/selectors/token.ts +++ b/packages/state/recoil/selectors/token.ts @@ -15,7 +15,7 @@ import { getChainForChainId, getChainForChainName, getFallbackImage, - getIbcTransferInfoFromChainSource, + getIbcTransferInfoFromChannel, getTokenForChainIdAndDenom, isValidContractAddress, isValidTokenFactoryDenom, @@ -430,7 +430,7 @@ export const sourceChainAndDenomSelector = selectorFamily< sourceChainId = channels.reduce( (currentChainId, channel) => getChainForChainName( - getIbcTransferInfoFromChainSource(currentChainId, channel) + getIbcTransferInfoFromChannel(currentChainId, channel) .destinationChain.chain_name ).chain_id, chainId diff --git a/packages/stateful/actions/core/advanced/CreateIcaAccount/Component.tsx b/packages/stateful/actions/core/advanced/CreateIcaAccount/Component.tsx new file mode 100644 index 0000000000..7b112861d3 --- /dev/null +++ b/packages/stateful/actions/core/advanced/CreateIcaAccount/Component.tsx @@ -0,0 +1,96 @@ +import { useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' + +import { + CopyToClipboard, + IbcDestinationChainPicker, + InputErrorMessage, + Loader, + WarningCard, + useChain, +} from '@dao-dao/stateless' +import { LoadingDataWithError } from '@dao-dao/types' +import { ActionComponent } from '@dao-dao/types/actions' +import { getDisplayNameForChainId, getImageUrlForChainId } from '@dao-dao/utils' + +export type CreateIcaAccountData = { + chainId: string +} + +export type CreateIcaAccountOptions = { + createdAddressLoading: LoadingDataWithError +} + +export const CreateIcaAccountComponent: ActionComponent< + CreateIcaAccountOptions +> = ({ + fieldNamePrefix, + isCreating, + errors, + options: { createdAddressLoading }, +}) => { + const { t } = useTranslation() + const { watch, setValue } = useFormContext() + const { chain_id: sourceChainId } = useChain() + + const destinationChainId = watch((fieldNamePrefix + 'chainId') as 'chainId') + const imageUrl = getImageUrlForChainId(destinationChainId) + + return ( + <> + {isCreating ? ( + <> + + setValue((fieldNamePrefix + 'chainId') as 'chainId', chainId) + } + selectedChainId={destinationChainId} + sourceChainId={sourceChainId} + /> + + + + ) : ( +
+
+ {imageUrl && ( +
+ )} + +

+ {getDisplayNameForChainId(destinationChainId)} +

+
+ + {createdAddressLoading.loading ? ( + + ) : createdAddressLoading.errored ? ( + + ) : createdAddressLoading.data ? ( + + ) : ( +

{t('info.pending')}

+ )} +
+ )} + + ) +} diff --git a/packages/stateful/actions/core/advanced/CreateIcaAccount/README.md b/packages/stateful/actions/core/advanced/CreateIcaAccount/README.md new file mode 100644 index 0000000000..418ac4d0d7 --- /dev/null +++ b/packages/stateful/actions/core/advanced/CreateIcaAccount/README.md @@ -0,0 +1,20 @@ +# CreateIcaAccount + +Create an account via Interchain Accounts IBC module. + +## 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 + +`createIcaAccount` + +### Data format + +```json +{ + "chainId": "" +} +``` diff --git a/packages/stateful/actions/core/advanced/CreateIcaAccount/index.tsx b/packages/stateful/actions/core/advanced/CreateIcaAccount/index.tsx new file mode 100644 index 0000000000..e758c6c0f2 --- /dev/null +++ b/packages/stateful/actions/core/advanced/CreateIcaAccount/index.tsx @@ -0,0 +1,169 @@ +import { useCallback, useEffect } from 'react' +import { useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' + +import { MsgRegisterInterchainAccount } from '@dao-dao/protobuf/codegen/ibc/applications/interchain_accounts/controller/v1/tx' +import { Metadata } from '@dao-dao/protobuf/codegen/ibc/applications/interchain_accounts/v1/metadata' +import { icaRemoteAddressSelector } from '@dao-dao/state/recoil' +import { ChainEmoji, useCachedLoadingWithError } from '@dao-dao/stateless' +import { + ActionComponent, + ActionKey, + ActionMaker, + UseDecodedCosmosMsg, + UseDefaults, + UseTransformToCosmos, +} from '@dao-dao/types' +import { + getChainForChainName, + getDisplayNameForChainId, + getIbcTransferInfoBetweenChains, + getIbcTransferInfoFromConnection, + isDecodedStargateMsg, + makeStargateMessage, +} from '@dao-dao/utils' + +import { useActionOptions } from '../../../react' +import { CreateIcaAccountComponent, CreateIcaAccountData } 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, + }) + ) + + // If ICA account already exists for this chain during creation, add error + // preventing submission. + useEffect(() => { + if ( + !createdAddressLoading.loading && + !createdAddressLoading.errored && + createdAddressLoading.data && + props.isCreating + ) { + setError((props.fieldNamePrefix + 'chainId') as 'chainId', { + type: 'manual', + message: t('error.icaAccountAlreadyExists', { + chain: getDisplayNameForChainId(destChainId), + }), + }) + } else { + clearErrors((props.fieldNamePrefix + 'chainId') as 'chainId') + } + }, [ + clearErrors, + createdAddressLoading, + destChainId, + props.fieldNamePrefix, + props.isCreating, + setError, + t, + ]) + + return ( + + ) +} + +export const makeCreateIcaAccountAction: ActionMaker = ({ + t, + chain: { chain_id: sourceChainId }, + address, +}) => { + const useDefaults: UseDefaults = () => ({ + chainId: '', + }) + + const useTransformToCosmos: UseTransformToCosmos = () => + useCallback(({ chainId }) => { + if (!chainId) { + return + } + + 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 + }, []) + + 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, + } + } + } + + return { + key: ActionKey.CreateIcaAccount, + Icon: ChainEmoji, + label: t('title.createICAAccount'), + description: t('info.createICAAccountDescription'), + Component, + useDefaults, + useTransformToCosmos, + useDecodedCosmosMsg, + } +} diff --git a/packages/stateful/actions/core/advanced/index.ts b/packages/stateful/actions/core/advanced/index.ts index 6e6d5d1b10..14f4639fad 100644 --- a/packages/stateful/actions/core/advanced/index.ts +++ b/packages/stateful/actions/core/advanced/index.ts @@ -1,6 +1,7 @@ import { ActionCategoryKey, ActionCategoryMaker } from '@dao-dao/types' import { makeBulkImportAction } from './BulkImport' +import { makeCreateIcaAccountAction } from './CreateIcaAccount' import { makeCrossChainExecuteAction } from './CrossChainExecute' import { makeCustomAction } from './Custom' @@ -11,6 +12,7 @@ export const makeAdvancedActionCategory: ActionCategoryMaker = ({ t }) => ({ actionMakers: [ makeCustomAction, makeCrossChainExecuteAction, + makeCreateIcaAccountAction, makeBulkImportAction, ], }) diff --git a/packages/stateful/actions/core/treasury/Spend/Component.tsx b/packages/stateful/actions/core/treasury/Spend/Component.tsx index 9df500afc2..0a78602193 100644 --- a/packages/stateful/actions/core/treasury/Spend/Component.tsx +++ b/packages/stateful/actions/core/treasury/Spend/Component.tsx @@ -1,23 +1,15 @@ import { - ArrowDropDown, ArrowRightAltRounded, SubdirectoryArrowRightRounded, } from '@mui/icons-material' -import { ibc } from 'chain-registry' import clsx from 'clsx' -import { - ComponentType, - RefAttributes, - useCallback, - useEffect, - useMemo, -} from 'react' +import { ComponentType, RefAttributes, useCallback, useEffect } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { ChainProvider, - FilterableItemPopup, + IbcDestinationChainPicker, InputErrorMessage, TokenAmountDisplay, TokenInput, @@ -35,11 +27,8 @@ import { convertDenomToMicroDenomWithDecimals, convertMicroDenomToDenomWithDecimals, getChainForChainId, - getChainForChainName, getDaoAccountAddress, - getImageUrlForChainId, makeValidateAddress, - toAccessibleImageUrl, transformBech32Address, validateRequired, } from '@dao-dao/utils' @@ -132,34 +121,6 @@ export const SpendComponent: ActionComponent = ({ } }, [context, currentEntity, fieldNamePrefix, recipient, setValue, toChain]) - const possibleDestinationChains = useMemo(() => { - const spendChain = getChainForChainId(spendChainId) - return [ - spendChain, - ...ibc - .filter( - ({ chain_1, chain_2, channels }) => - // Either chain is the source spend chain. - (chain_1.chain_name === spendChain.chain_name || - chain_2.chain_name === spendChain.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 === spendChain.chain_name ? chain_2 : chain_1 - return getChainForChainName(otherChain.chain_name) - }) - // Remove nonexistent osmosis testnet chain. - .filter((chain) => chain.chain_id !== 'osmo-test-4'), - ] - }, [spendChainId]) - const validatePossibleSpend = useCallback( (chainId: string, denom: string, amount: number): string | boolean => { if (tokens.loading) { @@ -339,48 +300,18 @@ export const SpendComponent: ActionComponent = ({ ref={toContainerRef} > {(isCreating || spendChainId !== toChainId) && ( - ({ - key: chain.chain_id, - label: chain.pretty_name, - iconUrl: getImageUrlForChainId(chain.chain_id), - iconClassName: '!h-8 !w-8', - contentContainerClassName: '!gap-3', - }))} - onSelect={({ key }) => - setValue((fieldNamePrefix + 'toChainId') as 'toChainId', key) + + setValue( + (fieldNamePrefix + 'toChainId') as 'toChainId', + chainId + ) } - searchPlaceholder={t('info.searchForChain')} - trigger={{ - type: 'button', - props: { - className: toWrapped ? 'grow' : undefined, - contentContainerClassName: - 'justify-between text-icon-primary !gap-4', - disabled: !isCreating, - size: 'lg', - variant: 'ghost_outline', - children: ( - <> -
-
- -

{toChain.pretty_name}

-
- - {isCreating && } - - ), - }, - }} + selectedChainId={toChainId} + sourceChainId={spendChainId} /> )} @@ -432,5 +363,3 @@ export const SpendComponent: ActionComponent = ({ ) } - -const FILTERABLE_KEYS = ['key', 'label'] diff --git a/packages/stateful/actions/core/treasury/Spend/index.tsx b/packages/stateful/actions/core/treasury/Spend/index.tsx index 47cd4929ef..1977fcb817 100644 --- a/packages/stateful/actions/core/treasury/Spend/index.tsx +++ b/packages/stateful/actions/core/treasury/Spend/index.tsx @@ -28,7 +28,7 @@ import { getChainForChainName, getDaoAccountAddress, getIbcTransferInfoBetweenChains, - getIbcTransferInfoFromChainSource, + getIbcTransferInfoFromChannel, isDecodedStargateMsg, isValidBech32Address, isValidContractAddress, @@ -295,7 +295,7 @@ const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( } if (isIbcTransfer) { - const { destinationChain } = getIbcTransferInfoFromChainSource( + const { destinationChain } = getIbcTransferInfoFromChannel( chainId, msg.stargate.value.sourceChannel ) diff --git a/packages/stateless/components/IbcDestinationChainPicker.tsx b/packages/stateless/components/IbcDestinationChainPicker.tsx new file mode 100644 index 0000000000..f9a675446b --- /dev/null +++ b/packages/stateless/components/IbcDestinationChainPicker.tsx @@ -0,0 +1,128 @@ +import { ArrowDropDown } from '@mui/icons-material' +import { ibc } from 'chain-registry' +import clsx from 'clsx' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +import { + getChainForChainId, + getChainForChainName, + getImageUrlForChainId, + toAccessibleImageUrl, +} from '@dao-dao/utils' + +import { FilterableItemPopup } from './popup/FilterableItemPopup' + +export type IbcDestinationChainPickerProps = { + // The source chain. + sourceChainId: string + // Whether or not to include the source chain in the list. + includeSourceChain: boolean + // The selected destination chain. + selectedChainId: string + // Chain selection handler. + onChainSelected: (chainId: string) => void + // Whether or not selection is disabled. + disabled?: boolean + // Class to apply to the selector button. + buttonClassName?: string +} + +// This component shows a picker for all destination chains that a source chain +// has an established IBC transfer channel with. +// +// Example usecases: +// - Spend action when choosing a destination chain for a transfer. +// - Create interchain account action. +export const IbcDestinationChainPicker = ({ + sourceChainId, + includeSourceChain, + selectedChainId, + onChainSelected, + disabled, + buttonClassName, +}: IbcDestinationChainPickerProps) => { + const { t } = useTranslation() + const chains = useMemo(() => { + const spendChain = getChainForChainId(sourceChainId) + return [ + // Source chain. + ...(includeSourceChain ? [spendChain] : []), + // IBC destination chains. + ...ibc + .filter( + ({ chain_1, chain_2, channels }) => + // Either chain is the source spend chain. + (chain_1.chain_name === spendChain.chain_name || + chain_2.chain_name === spendChain.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 === spendChain.chain_name ? chain_2 : chain_1 + return getChainForChainName(otherChain.chain_name) + }) + // Remove nonexistent osmosis testnet chain. + .filter((chain) => chain.chain_id !== 'osmo-test-4'), + ] + }, [includeSourceChain, sourceChainId]) + + const selectedChain = selectedChainId + ? getChainForChainId(selectedChainId) + : undefined + + return ( + ({ + key: chain.chain_id, + label: chain.pretty_name, + iconUrl: getImageUrlForChainId(chain.chain_id), + iconClassName: '!h-8 !w-8', + contentContainerClassName: '!gap-3', + }))} + onSelect={({ key }) => onChainSelected(key)} + searchPlaceholder={t('info.searchForChain')} + trigger={{ + type: 'button', + props: { + className: buttonClassName, + contentContainerClassName: 'justify-between text-icon-primary !gap-4', + disabled, + size: 'lg', + variant: 'ghost_outline', + children: ( + <> +
+ {selectedChain && ( +
+ )} + +

+ {selectedChain?.pretty_name || t('button.selectChain')} +

+
+ + {!disabled && } + + ), + }, + }} + /> + ) +} + +const FILTERABLE_KEYS = ['key', 'label'] diff --git a/packages/stateless/components/index.ts b/packages/stateless/components/index.ts index b35f5e721c..ee02ea4151 100644 --- a/packages/stateless/components/index.ts +++ b/packages/stateless/components/index.ts @@ -34,7 +34,7 @@ export * from './FormattedJsonDisplay' export * from './GridCardContainer' export * from './HorizontalNftCard' export * from './HorizontalScroller' -export * from './EntityDisplay' +export * from './IbcDestinationChainPicker' export * from './InfoCard' export * from './LineGraph' export * from './LinkWrapper' diff --git a/packages/types/actions.ts b/packages/types/actions.ts index 351727c1c6..cfc42cc955 100644 --- a/packages/types/actions.ts +++ b/packages/types/actions.ts @@ -64,6 +64,7 @@ export enum ActionKey { ManageVesting = 'manageVesting', CreateCrossChainAccount = 'createCrossChainAccount', CrossChainExecute = 'crossChainExecute', + CreateIcaAccount = 'createIcaAccount', ConfigureRebalancer = 'configureRebalancer', // DaoProposalSingle UpdatePreProposeSingleConfig = 'updatePreProposeSingleConfig', diff --git a/packages/utils/chain.ts b/packages/utils/chain.ts index a32891652e..dd46f31d37 100644 --- a/packages/utils/chain.ts +++ b/packages/utils/chain.ts @@ -279,6 +279,7 @@ export const getIbcTransferInfoBetweenChains = ( destChainId: string ): { sourceChain: IBCInfo['chain_1'] + destinationChain: IBCInfo['chain_1'] sourceChannel: string info: IBCInfo } => { @@ -305,12 +306,12 @@ export const getIbcTransferInfoBetweenChains = ( } const srcChainNumber = info.chain_1.chain_name === srcChainName ? 1 : 2 + const destChainNumber = info.chain_1.chain_name === destChainName ? 1 : 2 const channel = info.channels.find( ({ [`chain_${srcChainNumber}` as `chain_${typeof srcChainNumber}`]: srcChain, - [`chain_${ - srcChainNumber === 1 ? 2 : 1 - }` as `chain_${typeof srcChainNumber}`]: destChain, + [`chain_${destChainNumber}` as `chain_${typeof srcChainNumber}`]: + destChain, version, }) => version === 'ics20-1' && @@ -326,19 +327,20 @@ export const getIbcTransferInfoBetweenChains = ( return { sourceChain: info[`chain_${srcChainNumber}`], sourceChannel: channel[`chain_${srcChainNumber}`].channel_id, + destinationChain: info[`chain_${destChainNumber}`], info, } } -export const getIbcTransferInfoFromChainSource = ( - chainId: string, +export const getIbcTransferInfoFromChannel = ( + sourceChainId: string, sourceChannel: string ): { destinationChain: IBCInfo['chain_1'] channel: IBCInfo['channels'][number] info: IBCInfo } => { - const { chain_name } = getChainForChainId(chainId) + const { chain_name } = getChainForChainId(sourceChainId) const info = ibc.find( ({ chain_1, chain_2, channels }) => @@ -361,7 +363,7 @@ export const getIbcTransferInfoFromChainSource = ( ) if (!info) { throw new Error( - `Failed to find IBC channel for chain ${chainId} and source channel ${sourceChannel}.` + `Failed to find IBC channel for chain ${sourceChainId} and source channel ${sourceChannel}.` ) } @@ -381,7 +383,7 @@ export const getIbcTransferInfoFromChainSource = ( ) if (!channel) { throw new Error( - `Failed to find IBC channel for chain ${chainId} and source channel ${sourceChannel}.` + `Failed to find IBC channel for chain ${sourceChainId} and source channel ${sourceChannel}.` ) } @@ -392,6 +394,43 @@ export const getIbcTransferInfoFromChainSource = ( } } +export const getIbcTransferInfoFromConnection = ( + sourceChainId: string, + sourceConnectionId: string +): { + info: IBCInfo + destinationChain: IBCInfo['chain_1'] +} => { + const { chain_name } = getChainForChainId(sourceChainId) + + const info = ibc.find( + ({ chain_1, chain_2, channels }) => + ((chain_1.chain_name === chain_name && + chain_1.connection_id === sourceConnectionId) || + (chain_2.chain_name === chain_name && + chain_2.connection_id === sourceConnectionId)) && + channels.some( + ({ chain_1, chain_2, version }) => + version === 'ics20-1' && + chain_1.port_id === 'transfer' && + chain_2.port_id === 'transfer' + ) + ) + if (!info) { + throw new Error( + `Failed to find IBC info for source chain ${sourceChainId} and connection ${sourceConnectionId}.` + ) + } + + const thisChainNumber = info.chain_1.chain_name === chain_name ? 1 : 2 + const destinationChain = info[`chain_${thisChainNumber === 1 ? 2 : 1}`] + + return { + info, + destinationChain, + } +} + export const getSupportedChainConfig = ( chainId: string ): SupportedChainConfig | undefined =>