From 53a3c6ce65511d11bcade6f185f8bbe709f01c40 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Tue, 12 Nov 2024 23:26:44 -0500 Subject: [PATCH] support multi-NFT transfer in action --- packages/i18n/locales/en/translation.json | 14 +- .../actions/TransferNft/Component.stories.tsx | 4 +- .../core/actions/TransferNft/Component.tsx | 151 ++++++++---- .../core/actions/TransferNft/README.md | 13 +- .../core/actions/TransferNft/index.tsx | 226 +++++++++++------- 5 files changed, 261 insertions(+), 147 deletions(-) diff --git a/packages/i18n/locales/en/translation.json b/packages/i18n/locales/en/translation.json index bb58dc71e..87c6dfa76 100644 --- a/packages/i18n/locales/en/translation.json +++ b/packages/i18n/locales/en/translation.json @@ -238,6 +238,7 @@ "selectAllNfts": "Select all {{count}} NFTs", "selectChain": "Select chain", "selectNft": "Select NFT", + "selectNfts": "Select NFT(s)", "selectToken": "Select token", "selectValidator": "Select validator", "selectWidget": "Select widget", @@ -554,6 +555,7 @@ "relayerNotSetUp": "Relayer not set up.", "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.", + "selectedNftsMustBeFromSameChain": "All selected NFTs must be from the same chain.", "simulationFailedInvalidProposalActions": "Simulation failed. Verify your proposal actions are valid.", "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.", @@ -889,7 +891,7 @@ "whoCanUseContract": "Who can use this contract?", "whoCanVetoProposals": "Who can veto proposals?", "whoIsCounterparty": "Who is the counterparty?", - "whoTransferNftQuestion": "Where would you like to transfer the NFT?", + "whoTransferNftsQuestion": "Where would you like to transfer the NFT(s)?", "widget": "Widget", "withdrawAddress": "Withdraw address" }, @@ -1486,9 +1488,9 @@ "totalStakedTooltip": "The amount of ${{tokenSymbol}} currently staked with the DAO and thus earning voting power.", "totalSupplyTooltip": "The amount of ${{tokenSymbol}} in existence.", "transactionBuilderDescription": "Build transactions with the various actions to execute from your wallet.", - "transferNftDescription_dao": "Transfer an NFT out of the DAO's treasury.", - "transferNftDescription_gov": "Transfer an NFT from the Community Pool.", - "transferNftDescription_wallet": "Transfer an NFT from your wallet.", + "transferNftsDescription_dao": "Transfer NFT(s) out of the DAO's treasury.", + "transferNftsDescription_gov": "Transfer NFT(s) from the Community Pool.", + "transferNftsDescription_wallet": "Transfer NFT(s) from your wallet.", "treasuryBalanceDescription": "{{numberOfTokensMinted, number}} tokens will be minted. {{memberPercent}} will be sent to members according to the distribution below. The remaining {{treasuryPercent}} will go to the DAO's treasury, where they can be distributed later via governance proposals.", "treasuryHistoryTooltip": "The graph below displays the historical value of the treasury across all chains.", "treasuryPercent": "Treasury percent", @@ -2022,7 +2024,7 @@ "scanQrCode": "Scan QR Code", "search": "Search", "selectNftToBurn": "Select NFT To Burn", - "selectNftToTransfer": "Select NFT To Transfer", + "selectNftsToTransfer": "Select NFT(s) To Transfer", "send": "Send", "setAdminToParent": "Set admin to {{parent}}", "setItem": "Set Item", @@ -2078,7 +2080,7 @@ "transaction": "Transaction", "transactionBuilder": "Transaction Builder", "transfer": "Transfer", - "transferNft": "Transfer NFT", + "transferNfts": "Transfer NFT(s)", "treasury": "Treasury", "treasuryHistory": "Treasury History", "treasuryValue": "Treasury Value", diff --git a/packages/stateful/actions/core/actions/TransferNft/Component.stories.tsx b/packages/stateful/actions/core/actions/TransferNft/Component.stories.tsx index 4ad4a597a..ea2e920f5 100644 --- a/packages/stateful/actions/core/actions/TransferNft/Component.stories.tsx +++ b/packages/stateful/actions/core/actions/TransferNft/Component.stories.tsx @@ -40,10 +40,10 @@ Default.args = { isCreating: true, errors: {}, options: { - nftInfo: { + nftInfos: { loading: false, errored: false, - data: selected, + data: [selected], }, options: { loading: false, diff --git a/packages/stateful/actions/core/actions/TransferNft/Component.tsx b/packages/stateful/actions/core/actions/TransferNft/Component.tsx index 37a6e77d9..effd60041 100644 --- a/packages/stateful/actions/core/actions/TransferNft/Component.tsx +++ b/packages/stateful/actions/core/actions/TransferNft/Component.tsx @@ -2,6 +2,7 @@ import { Check, Close } from '@mui/icons-material' import clsx from 'clsx' import { ComponentType, useEffect, useState } from 'react' import { useFormContext } from 'react-hook-form' +import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' import { @@ -33,11 +34,12 @@ import { export type TransferNftData = { chainId: string - collection: string - tokenId: string + nfts: { + collection: string + tokenId: string + }[] recipient: string - - // When true, uses `send` instead of `transfer_nft` to transfer the NFT. + // When true, uses `send` instead of `transfer_nft` to transfer the NFT(s). executeSmartContract: boolean smartContractMsg: string } @@ -45,9 +47,9 @@ export type TransferNftData = { export interface TransferNftOptions { // The set of NFTs that may be transfered as part of this action. options: LoadingDataWithError - // Information about the NFT currently selected. If undefined, no NFT is + // Information about the NFTs currently selected. If undefined, no NFTs are // selected. - nftInfo: LoadingDataWithError | undefined + nftInfos: LoadingDataWithError | undefined AddressInput: ComponentType> NftSelectionModal: ComponentType @@ -57,37 +59,57 @@ export const TransferNftComponent: ActionComponent = ({ fieldNamePrefix, isCreating, errors, - options: { options, nftInfo, AddressInput, NftSelectionModal }, + options: { options, nftInfos, AddressInput, NftSelectionModal }, }) => { const { t } = useTranslation() - const { control, watch, setValue, setError, register, clearErrors } = - useFormContext() + const { + control, + watch, + setValue, + getValues, + setError, + register, + clearErrors, + } = useFormContext() const chainId = watch((fieldNamePrefix + 'chainId') as 'chainId') const chain = getChainForChainId(chainId) - const tokenId = watch((fieldNamePrefix + 'tokenId') as 'tokenId') - const collection = watch((fieldNamePrefix + 'collection') as 'collection') + const nfts = watch((fieldNamePrefix + 'nfts') as 'nfts') const executeSmartContract = watch( (fieldNamePrefix + 'executeSmartContract') as 'executeSmartContract' ) - const selectedKey = getNftKey(chainId, collection, tokenId) - useEffect(() => { - if (!selectedKey) { - setError((fieldNamePrefix + 'collection') as 'collection', { + if (!nfts.length) { + setError((fieldNamePrefix + 'nfts') as 'nfts', { type: 'required', message: t('error.noNftSelected'), }) } else { - clearErrors((fieldNamePrefix + 'collection') as 'collection') + clearErrors((fieldNamePrefix + 'nfts') as 'nfts') } - }, [selectedKey, setError, clearErrors, t, fieldNamePrefix]) + }, [nfts.length, setError, clearErrors, t, fieldNamePrefix]) // Show modal initially if creating and no NFT already selected. const [showModal, setShowModal] = useState( - isCreating && !selectedKey + isCreating && !nfts.length + ) + + // If any NFT is selected, only show NFTs from that chain. Otherwise, show all + // NFTs. + const nftOptions: LoadingDataWithError = + options.loading || options.errored || nfts.length === 0 + ? options + : { + loading: false, + errored: false, + updating: options.updating, + data: options.data.filter((o) => o.chainId === chainId), + } + + const selectedKeys = nfts.map((nft) => + getNftKey(chainId, nft.collection, nft.tokenId) ) return ( @@ -95,11 +117,15 @@ export const TransferNftComponent: ActionComponent = ({
-

- {isCreating - ? t('form.whoTransferNftQuestion') - : t('form.recipient')} -

+ = ({
- {nftInfo && - (nftInfo.loading ? ( + {!isCreating && ( + + )} + + {nftInfos && + (nftInfos.loading ? ( - ) : nftInfo.errored ? ( - + ) : nftInfos.errored ? ( + ) : ( - +
+ {nftInfos.data.map(({ key, ...nftInfo }) => ( + + ))} +
))} {isCreating && ( )} - +
@@ -221,24 +258,52 @@ export const TransferNftComponent: ActionComponent = ({ onClick: () => setShowModal(false), }} header={{ - title: t('title.selectNftToTransfer'), + title: t('title.selectNftsToTransfer'), }} - nfts={options} + nfts={nftOptions} onClose={() => setShowModal(false)} onNftClick={(nft) => { - if (nft.key === selectedKey) { - setValue((fieldNamePrefix + 'tokenId') as 'tokenId', '') - setValue((fieldNamePrefix + 'collection') as 'collection', '') - } else { + const selected = getValues((fieldNamePrefix + 'nfts') as 'nfts') + + // If no NFTs are selected, set the chain and selected NFT. + if (selected.length === 0) { setValue((fieldNamePrefix + 'chainId') as 'chainId', nft.chainId) - setValue((fieldNamePrefix + 'tokenId') as 'tokenId', nft.tokenId) + setValue((fieldNamePrefix + 'nfts') as 'nfts', [ + { + collection: nft.collectionAddress, + tokenId: nft.tokenId, + }, + ]) + } else if ( + // If the NFT is already selected, remove it. + selected.some( + (n) => + n.collection === nft.collectionAddress && + n.tokenId === nft.tokenId + ) + ) { setValue( - (fieldNamePrefix + 'collection') as 'collection', - nft.collectionAddress + (fieldNamePrefix + 'nfts') as 'nfts', + nfts.filter( + (n) => getNftKey(chainId, n.collection, n.tokenId) !== nft.key + ) ) + } else if (nft.chainId === chainId) { + // Otherwise, add the NFT if from the same chain. + setValue((fieldNamePrefix + 'nfts') as 'nfts', [ + ...selected, + { + collection: nft.collectionAddress, + tokenId: nft.tokenId, + }, + ]) + } else { + // This should never happen since we filter NFTs based on the + // chain of the first one selected, but if it does, show an error. + toast.error(t('error.selectedNftsMustBeFromSameChain')) } }} - selectedKeys={selectedKey ? [selectedKey] : []} + selectedKeys={selectedKeys} visible={showModal} /> )} diff --git a/packages/stateful/actions/core/actions/TransferNft/README.md b/packages/stateful/actions/core/actions/TransferNft/README.md index df1c34754..fcb374689 100644 --- a/packages/stateful/actions/core/actions/TransferNft/README.md +++ b/packages/stateful/actions/core/actions/TransferNft/README.md @@ -1,6 +1,6 @@ # TransferNft -Send an NFT owned by the current account. +Send one or more NFTs owned by the current account. ## Bulk import format @@ -15,10 +15,15 @@ guide](https://github.com/DA0-DA0/dao-dao-ui/wiki/Bulk-importing-actions). ```json { - "collection": "", - "tokenId": "", + "chainId": "", + "nfts": [ + { + "collection": "", + "tokenId": "" + }, + ... + ], "recipient": "", - "executeSmartContract": , "smartContractMsg": "" } diff --git a/packages/stateful/actions/core/actions/TransferNft/index.tsx b/packages/stateful/actions/core/actions/TransferNft/index.tsx index 709f79ad3..99ac55198 100644 --- a/packages/stateful/actions/core/actions/TransferNft/index.tsx +++ b/packages/stateful/actions/core/actions/TransferNft/index.tsx @@ -1,4 +1,4 @@ -import { useQueryClient } from '@tanstack/react-query' +import { useQueries, useQueryClient } from '@tanstack/react-query' import JSON5 from 'json5' import { useFormContext } from 'react-hook-form' @@ -29,13 +29,13 @@ import { decodeJsonFromBase64, encodeJsonToBase64, getChainAddressForActionOptions, + makeCombineQueryResultsIntoLoadingDataWithError, makeExecuteSmartContractMessage, maybeMakePolytoneExecuteMessages, objectMatchesStructure, } from '@dao-dao/utils' import { AddressInput, NftSelectionModal } from '../../../../components' -import { useQueryLoadingDataWithError } from '../../../../hooks' import { useCw721CommonGovernanceTokenInfoIfExists } from '../../../../voting-module-adapter' import { TransferNftComponent, TransferNftData } from './Component' @@ -51,10 +51,7 @@ const Component: ActionComponent = (props) => { 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 nfts = watch((props.fieldNamePrefix + 'nfts') as 'nfts') const options = useCachedLoadingWithError( props.isCreating @@ -70,11 +67,15 @@ const Component: ActionComponent = (props) => { }) : undefined ) - const nftInfo = useQueryLoadingDataWithError( - chainId && collection && tokenId - ? nftQueries.cardInfo(queryClient, { chainId, collection, tokenId }) - : undefined - ) + const nftInfos = useQueries({ + queries: + chainId && nfts.length + ? nfts.map(({ collection, tokenId }) => + nftQueries.cardInfo(queryClient, { chainId, collection, tokenId }) + ) + : [], + combine: makeCombineQueryResultsIntoLoadingDataWithError(), + }) const allChainOptions = options.loading || options.errored @@ -90,7 +91,7 @@ const Component: ActionComponent = (props) => { {...props} options={{ options: allChainOptions, - nftInfo: chainId && tokenId && collection ? nftInfo : undefined, + nftInfos: chainId && nfts.length ? nftInfos : undefined, AddressInput, NftSelectionModal, }} @@ -105,18 +106,16 @@ export class TransferNftAction extends ActionBase { constructor(options: ActionOptions) { super(options, { Icon: BoxEmoji, - label: options.t('title.transferNft'), - description: options.t('info.transferNftDescription', { + label: options.t('title.transferNfts'), + description: options.t('info.transferNftsDescription', { context: options.context.type, }), }) this.defaults = { chainId: options.chain.chainId, - collection: '', - tokenId: '', + nfts: [], recipient: '', - executeSmartContract: false, smartContractMsg: '{}', } @@ -124,8 +123,7 @@ export class TransferNftAction extends ActionBase { encode({ chainId, - collection, - tokenId, + nfts, recipient, executeSmartContract, smartContractMsg, @@ -138,30 +136,40 @@ export class TransferNftAction extends ActionBase { return maybeMakePolytoneExecuteMessages( this.options.chain.chainId, 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, + nfts.map(({ collection, tokenId }) => + 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 ( + // This should match one or more identical same-chain transfers or one + // cross-chain message with one or more idential transfers. The only thing + // that can differ is the NFT being transferred. + handleMessages(_messages: ProcessedMessage[]) { + const messages = _messages[0].isCrossChain + ? _messages[0].wrappedMessages + : _messages + + // Detect all transfers. + const all = messages.map(({ decodedMessage, account: { chainId } }) => objectMatchesStructure(decodedMessage, { wasm: { execute: { @@ -175,63 +183,97 @@ export class TransferNftAction extends ActionBase { }, }, }, - }) || - objectMatchesStructure(decodedMessage, { - wasm: { - execute: { - contract_addr: {}, - funds: {}, - msg: { - send_nft: { - contract: {}, - msg: {}, - token_id: {}, + }) + ? { + 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: '{}', + } + : objectMatchesStructure(decodedMessage, { + wasm: { + execute: { + contract_addr: {}, + funds: {}, + msg: { + send_nft: { + contract: {}, + msg: {}, + token_id: {}, + }, + }, }, }, - }, - }, - }) + }) + ? { + 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 + ), + } + : null ) + + // If the first is not a transfer, match none. + if (!all.length || !all[0]) { + return [] + } + + // Select all identical adjacent transfers starting with the first. Once one + // does not match, stop. + const transfers = [] + for (const transfer of all) { + if (!transfer) { + continue + } + + // If this is the first transfer, add it. + if (!transfers.length) { + transfers.push(transfer) + } else if ( + // If the chainId, recipient, executeSmartContract, and smartContractMsg + // match, add it. + transfer.chainId === transfers[0].chainId && + transfer.recipient === transfers[0].recipient && + transfer.executeSmartContract === transfers[0].executeSmartContract && + transfer.smartContractMsg === transfers[0].smartContractMsg + ) { + transfers.push(transfer) + } else { + // If it doesn't match, stop. + break + } + } + + return transfers } - 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 - ), - } + match(messages: ProcessedMessage[]): ActionMatch { + return this.handleMessages(messages).length + } + + decode(messages: ProcessedMessage[]): TransferNftData { + const transfers = this.handleMessages(messages) + return { + chainId: transfers[0].chainId, + nfts: transfers.map(({ collection, tokenId }) => ({ + collection, + tokenId, + })), + recipient: transfers[0].recipient, + executeSmartContract: transfers[0].executeSmartContract, + smartContractMsg: transfers[0].smartContractMsg, + } } }