diff --git a/examples/homepage/src/app/components/features/FeaturesToTry/FeaturesToTry.tsx b/examples/homepage/src/app/components/features/FeaturesToTry/FeaturesToTry.tsx index aff2894..ebb0037 100644 --- a/examples/homepage/src/app/components/features/FeaturesToTry/FeaturesToTry.tsx +++ b/examples/homepage/src/app/components/features/FeaturesToTry/FeaturesToTry.tsx @@ -16,18 +16,22 @@ import { useEmbeddedWalletSettingsModal, useExploreEcosystemModal, useNotificationsModal, + useAccountCustomizationModal, useFAQModal, } from '@vechain/vechain-kit'; import { FeatureCard } from './FeatureCard'; import { GithubCard } from './GithubCard'; import { LanguageCard } from './LanguageCard'; import { ThemeCard } from './ThemeCard'; +import { CgProfile } from 'react-icons/cg'; export function FeaturesToTry() { const { account } = useWallet(); // Use the modal hooks const { open: openChooseNameModal } = useChooseNameModal(); + const { open: openAccountCustomizationModal } = + useAccountCustomizationModal(); const { open: openSendTokenModal } = useSendTokenModal(); const { open: openEmbeddedWalletSettingsModal } = useEmbeddedWalletSettingsModal(); @@ -45,6 +49,15 @@ export function FeaturesToTry() { link: '#', content: openChooseNameModal, }, + { + title: 'Set Profile Image', + description: + 'Customize your account with a profile image to enhance your identity across VeChain applications.', + icon: CgProfile, + highlight: !account?.domain, + link: '#', + content: openAccountCustomizationModal, + }, { title: 'Transfer Assets', description: @@ -89,7 +102,7 @@ export function FeaturesToTry() { return ( - Features (click to try!) + Features @@ -97,8 +110,8 @@ export function FeaturesToTry() { ))} - + ); diff --git a/examples/homepage/src/app/pages/Home.tsx b/examples/homepage/src/app/pages/Home.tsx index aa94e7b..323c088 100644 --- a/examples/homepage/src/app/pages/Home.tsx +++ b/examples/homepage/src/app/pages/Home.tsx @@ -234,7 +234,15 @@ const Logo = () => { transition: 'opacity 0.2s ease-in-out', }} > - + + + Powered by + + + ); }; diff --git a/packages/vechain-kit/package.json b/packages/vechain-kit/package.json index e410df5..fc077ab 100644 --- a/packages/vechain-kit/package.json +++ b/packages/vechain-kit/package.json @@ -38,6 +38,7 @@ "@vechain/vebetterdao-contracts": "^4.1.0", "@wagmi/core": "^2.13.4", "bignumber.js": "^9.1.2", + "browser-image-compression": "^2.0.2", "buffer": "^6.0.3", "crypto-browserify": "^3.12.0", "dotenv": "^16.4.7", diff --git a/packages/vechain-kit/src/components/AccountModal/AccountModal.tsx b/packages/vechain-kit/src/components/AccountModal/AccountModal.tsx index 2d91882..65846a2 100644 --- a/packages/vechain-kit/src/components/AccountModal/AccountModal.tsx +++ b/packages/vechain-kit/src/components/AccountModal/AccountModal.tsx @@ -23,6 +23,7 @@ import { NotificationsContent } from './Contents/Notifications/NotificationConte import { ExploreEcosystemContent } from './Contents/Ecosystem/ExploreEcosystemContent'; import { AppOverviewContent } from './Contents/Ecosystem/AppOverviewContent'; import { DisconnectConfirmContent } from './Contents/Account/DisconnectConfirmContent'; +import { AccountCustomizationContent } from './Contents/Account/AccountCustomizationContent'; type Props = { isOpen: boolean; @@ -144,6 +145,12 @@ export const AccountModal = ({ setCurrentContent={setCurrentContent} /> ); + case 'account-customization': + return ( + + ); } }; diff --git a/packages/vechain-kit/src/components/AccountModal/Components/AccountSelector.tsx b/packages/vechain-kit/src/components/AccountModal/Components/AccountSelector.tsx index 4703a85..c60af9c 100644 --- a/packages/vechain-kit/src/components/AccountModal/Components/AccountSelector.tsx +++ b/packages/vechain-kit/src/components/AccountModal/Components/AccountSelector.tsx @@ -38,7 +38,7 @@ export const AccountSelector = ({ rounded="full" /> - {humanDomain(wallet?.domain ?? '', 11, 4) || + {humanDomain(wallet?.domain ?? '', 6, 4) || humanAddress(wallet?.address ?? '', 6, 4)} diff --git a/packages/vechain-kit/src/components/AccountModal/Components/ActionButton.tsx b/packages/vechain-kit/src/components/AccountModal/Components/ActionButton.tsx index 9e06d0b..91c840e 100644 --- a/packages/vechain-kit/src/components/AccountModal/Components/ActionButton.tsx +++ b/packages/vechain-kit/src/components/AccountModal/Components/ActionButton.tsx @@ -8,16 +8,17 @@ import { Image, Tag, useColorMode, + ButtonProps, } from '@chakra-ui/react'; -import { ElementType } from 'react'; import { useTranslation } from 'react-i18next'; +import { IconType } from 'react-icons'; -interface ActionButtonProps { +type ActionButtonProps = { title: string; description: string; onClick: () => void; - leftIcon?: ElementType; - rightIcon?: ElementType; + leftIcon?: IconType; + rightIcon?: IconType; leftImage?: string; backgroundColor?: string; border?: string; @@ -26,7 +27,10 @@ interface ActionButtonProps { showComingSoon?: boolean; isDisabled?: boolean; stacked?: boolean; -} + isLoading?: boolean; + loadingText?: string; + style?: ButtonProps; +}; export const ActionButton = ({ leftIcon, @@ -41,6 +45,9 @@ export const ActionButton = ({ _hover, isDisabled = false, stacked = false, + isLoading, + loadingText, + style, }: ActionButtonProps) => { const { t } = useTranslation(); const { colorMode } = useColorMode(); @@ -57,13 +64,23 @@ export const ActionButton = ({ onClick={onClick} display={hide ? 'none' : 'flex'} isDisabled={showComingSoon || isDisabled} + isLoading={isLoading} + loadingText={loadingText} bgColor={baseBackgroundColor} _hover={_hover} + {...style} > {leftImage ? ( - left-image + left-image ) : ( )} diff --git a/packages/vechain-kit/src/components/AccountModal/Contents/Account/AccountCustomizationContent.tsx b/packages/vechain-kit/src/components/AccountModal/Contents/Account/AccountCustomizationContent.tsx new file mode 100644 index 0000000..6048b45 --- /dev/null +++ b/packages/vechain-kit/src/components/AccountModal/Contents/Account/AccountCustomizationContent.tsx @@ -0,0 +1,208 @@ +import { + ModalBody, + ModalCloseButton, + ModalHeader, + VStack, + Text, + Button, +} from '@chakra-ui/react'; +import { ModalBackButton, StickyHeaderContainer } from '@/components/common'; +import { AccountModalContentTypes } from '../../Types'; +import { useTranslation } from 'react-i18next'; +import { useVeChainKitConfig } from '@/providers'; +import { getAvatarQueryKey, useWallet } from '@/hooks'; +import { MdOutlineNavigateNext } from 'react-icons/md'; +import { ActionButton } from '../../Components'; +import { useSingleImageUpload } from '@/hooks/api/ipfs'; +import { useUpdateAvatarRecord } from '@/hooks'; +import { useQueryClient } from '@tanstack/react-query'; +import { useRef, useState } from 'react'; +import { uploadBlobToIPFS } from '@/utils/ipfs'; +import { FaRegAddressCard } from 'react-icons/fa'; + +type Props = { + setCurrentContent: React.Dispatch< + React.SetStateAction + >; +}; + +export const AccountCustomizationContent = ({ setCurrentContent }: Props) => { + const { t } = useTranslation(); + const { network, darkMode: isDark } = useVeChainKitConfig(); + const { account } = useWallet(); + const fileInputRef = useRef(null); + const [isUploading, setIsUploading] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [showFullText, setShowFullText] = useState(false); + const queryClient = useQueryClient(); + const { onUpload } = useSingleImageUpload({ + compressImage: true, + }); + + const { updateAvatar } = useUpdateAvatarRecord({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: getAvatarQueryKey(account?.domain ?? ''), + }); + + await queryClient.refetchQueries({ + queryKey: getAvatarQueryKey(account?.domain ?? ''), + }); + + setIsProcessing(false); + }, + onError: () => { + setIsProcessing(false); + }, + }); + + const handleImageUpload = async ( + event: React.ChangeEvent, + ) => { + const file = event.target.files?.[0]; + if (!file) return; + + try { + setIsUploading(true); + setIsProcessing(true); + const uploadedImage = await onUpload(file); + if (!uploadedImage) throw new Error('Failed to compress image'); + + const ipfsHash = await uploadBlobToIPFS( + uploadedImage.file, + file.name, + network.type, + ); + await updateAvatar(account?.domain ?? '', 'ipfs://' + ipfsHash); + } catch (error) { + console.error('Error uploading image:', error); + setIsProcessing(false); + } finally { + setIsUploading(false); + } + }; + + return ( + <> + + + {t('Customize Account')} + + setCurrentContent('settings')} + /> + + + + + + + + {t( + 'Customize your account with a unique .vet domain name and profile image to enhance your identity across VeChain applications.', + )} + + + {showFullText && ( + <> + + {t( + 'Your customizations are linked to your .vet domain name, making them portable across different applications.', + )} + + + {t( + 'To get started with customization, first secure your .vet domain name. Once you have a domain, you can add a profile image that will be visible wherever you use your account.', + )} + + + {t( + 'Changing your domain name will update also your profile image.', + )} + + + )} + + + + + { + if (account?.domain) { + setCurrentContent({ + type: 'choose-name-search', + props: { + name: '', + setCurrentContent, + }, + }); + } else { + setCurrentContent('choose-name'); + } + }} + leftIcon={FaRegAddressCard} + rightIcon={MdOutlineNavigateNext} + /> + + fileInputRef.current?.click()} + leftImage={account?.image} + isLoading={isUploading || isProcessing} + isDisabled={!account?.domain} + loadingText={ + isUploading + ? t('Uploading image') + : t('Setting image') + } + /> + + + await handleImageUpload(event) + } + /> + + + + ); +}; diff --git a/packages/vechain-kit/src/components/AccountModal/Contents/Account/EmbeddedWalletContent.tsx b/packages/vechain-kit/src/components/AccountModal/Contents/Account/EmbeddedWalletContent.tsx index 23d2dab..fea06a2 100644 --- a/packages/vechain-kit/src/components/AccountModal/Contents/Account/EmbeddedWalletContent.tsx +++ b/packages/vechain-kit/src/components/AccountModal/Contents/Account/EmbeddedWalletContent.tsx @@ -73,7 +73,7 @@ export const EmbeddedWalletContent = ({ setCurrentContent }: Props) => { @@ -90,7 +90,7 @@ export const EmbeddedWalletContent = ({ setCurrentContent }: Props) => { <> {t( - 'This is your main wallet and identity. Please be sure to keep it safe and backed up. Go to {{element}} website to manage your login methods and security settings.', + 'This is your main wallet, created by {{element}} and secured by Privy. This wallet is the owner of your smart account, which is used as your identity and as a gateway for your blockchain interactions. Please be sure to keep it safe and backed up.', { element: connectionCache?.ecosystemApp?.name, @@ -115,6 +115,11 @@ export const EmbeddedWalletContent = ({ setCurrentContent }: Props) => { {showFullText && ( <> + + {t( + 'This wallet is the owner of your smart account, which is used as your identity and as a gateway for your blockchain interactions.', + )} + {t( 'We highly recommend exporting your private key to back up your wallet. This ensures you can restore it if needed or transfer it to self-custody using', @@ -147,11 +152,6 @@ export const EmbeddedWalletContent = ({ setCurrentContent }: Props) => { 'to learn more about embedded wallets.', )} - - {t( - 'A smart account is being used as a gateway for blockchain interactions.', - )} - )} @@ -168,6 +168,8 @@ export const EmbeddedWalletContent = ({ setCurrentContent }: Props) => { )} + {/* TODO: Go to {{element}} website to manage your login methods and security settings. */} + { leftIcon={MdManageAccounts} rightIcon={MdOutlineNavigateNext} /> - - {/* {connection.isConnectedWithSocialLogin && - privy?.allowPasskeyLinking && ( - { - linkPasskey(); - }} - leftIcon={IoIosFingerPrint} - rightIcon={MdOutlineNavigateNext} - /> - )} */} diff --git a/packages/vechain-kit/src/components/AccountModal/Contents/Account/WalletSettingsContent.tsx b/packages/vechain-kit/src/components/AccountModal/Contents/Account/WalletSettingsContent.tsx index d0360e7..fb4c6e2 100644 --- a/packages/vechain-kit/src/components/AccountModal/Contents/Account/WalletSettingsContent.tsx +++ b/packages/vechain-kit/src/components/AccountModal/Contents/Account/WalletSettingsContent.tsx @@ -23,13 +23,13 @@ import { } from '@/components/common'; import { useVeChainKitConfig } from '@/providers/VeChainKitProvider'; import { AccountModalContentTypes } from '../../Types'; -import { FaRegAddressCard } from 'react-icons/fa'; import { useTranslation } from 'react-i18next'; import { VscDebugDisconnect } from 'react-icons/vsc'; import { HiOutlineWallet } from 'react-icons/hi2'; import { useEffect, useRef } from 'react'; import { RiLogoutBoxLine } from 'react-icons/ri'; import { BsQuestionCircle } from 'react-icons/bs'; +import { GiPaintBrush } from 'react-icons/gi'; type Props = { setCurrentContent: React.Dispatch< @@ -49,8 +49,6 @@ export const WalletSettingsContent = ({ const { connection, disconnect, account } = useWallet(); - const hasExistingDomain = !!account?.domain; - const { getConnectionCache } = useCrossAppConnectionCache(); const connectionCache = getConnectionCache(); @@ -130,28 +128,14 @@ export const WalletSettingsContent = ({ /> { - if (hasExistingDomain) { - setCurrentContent({ - type: 'choose-name-search', - props: { - name: '', - setCurrentContent, - }, - }); - } else { - setCurrentContent('choose-name'); - } + setCurrentContent('account-customization'); }} - leftIcon={FaRegAddressCard} + leftIcon={GiPaintBrush} rightIcon={MdOutlineNavigateNext} /> diff --git a/packages/vechain-kit/src/components/AccountModal/Contents/Account/index.ts b/packages/vechain-kit/src/components/AccountModal/Contents/Account/index.ts index b00c215..cca8213 100644 --- a/packages/vechain-kit/src/components/AccountModal/Contents/Account/index.ts +++ b/packages/vechain-kit/src/components/AccountModal/Contents/Account/index.ts @@ -1,3 +1,4 @@ export * from './AccountMainContent'; export * from './EmbeddedWalletContent'; export * from './WalletSettingsContent'; +export * from './AccountCustomizationContent'; diff --git a/packages/vechain-kit/src/components/AccountModal/Contents/ChooseName/ChooseNameSearchContent.tsx b/packages/vechain-kit/src/components/AccountModal/Contents/ChooseName/ChooseNameSearchContent.tsx index dc8878f..c2f70f3 100644 --- a/packages/vechain-kit/src/components/AccountModal/Contents/ChooseName/ChooseNameSearchContent.tsx +++ b/packages/vechain-kit/src/components/AccountModal/Contents/ChooseName/ChooseNameSearchContent.tsx @@ -158,7 +158,7 @@ export const ChooseNameSearchContent = ({ onClick={() => // if the user has a domain, go to accounts account?.domain - ? setCurrentContent('settings') + ? setCurrentContent('account-customization') : setCurrentContent('choose-name') } /> diff --git a/packages/vechain-kit/src/components/AccountModal/Contents/ChooseName/ChooseNameSummaryContent.tsx b/packages/vechain-kit/src/components/AccountModal/Contents/ChooseName/ChooseNameSummaryContent.tsx index e0626c0..61e128f 100644 --- a/packages/vechain-kit/src/components/AccountModal/Contents/ChooseName/ChooseNameSummaryContent.tsx +++ b/packages/vechain-kit/src/components/AccountModal/Contents/ChooseName/ChooseNameSummaryContent.tsx @@ -121,7 +121,7 @@ export const ChooseNameSummaryContent = ({ isOpen={transactionModal.isOpen} onClose={() => { transactionModal.onClose(); - setCurrentContent('main'); + setCurrentContent('account-customization'); }} status={status} txId={txReceipt?.meta.txID} diff --git a/packages/vechain-kit/src/components/AccountModal/Contents/PrivyLinkedAccounts/PrivyLinkedAccounts.tsx b/packages/vechain-kit/src/components/AccountModal/Contents/PrivyLinkedAccounts/PrivyLinkedAccounts.tsx index f9a7b9b..df7675f 100644 --- a/packages/vechain-kit/src/components/AccountModal/Contents/PrivyLinkedAccounts/PrivyLinkedAccounts.tsx +++ b/packages/vechain-kit/src/components/AccountModal/Contents/PrivyLinkedAccounts/PrivyLinkedAccounts.tsx @@ -37,6 +37,7 @@ import { useTranslation } from 'react-i18next'; import { useState } from 'react'; import { useVeChainKitConfig } from '@/providers'; import { IoIosFingerPrint } from 'react-icons/io'; +import { MdOutlineNavigateNext } from 'react-icons/md'; type ConfirmUnlinkProps = { accountType: string; @@ -306,6 +307,19 @@ export const PrivyLinkedAccounts = ({ onBack }: PrivyLinkedAccountsProps) => { + { + linkPasskey(); + }} + leftIcon={IoIosFingerPrint} + rightIcon={MdOutlineNavigateNext} + isDisabled={!privy?.allowPasskeyLinking} + /> + {canLinkGoogle && ( { const { account } = useWallet(); const [isDesktop] = useMediaQuery('(min-width: 768px)'); diff --git a/packages/vechain-kit/src/config/index.ts b/packages/vechain-kit/src/config/index.ts index aa2b37f..f1defe6 100644 --- a/packages/vechain-kit/src/config/index.ts +++ b/packages/vechain-kit/src/config/index.ts @@ -34,6 +34,7 @@ export type AppConfig = { vetDomainsPublicResolverAddress: string; vetDomainsReverseRegistrarAddress: string; vnsResolverAddress: string; + vetDomainAvatarUrl: string; nodeUrl: string; indexerUrl: string; b3trIndexerUrl: string; diff --git a/packages/vechain-kit/src/config/mainnet.ts b/packages/vechain-kit/src/config/mainnet.ts index a8658f0..64b94b7 100644 --- a/packages/vechain-kit/src/config/mainnet.ts +++ b/packages/vechain-kit/src/config/mainnet.ts @@ -87,5 +87,6 @@ const config: AppConfig = { vetDomainsReverseRegistrarAddress: '0x5c970901a587BA3932C835D4ae5FAE2BEa7e78Bc', vnsResolverAddress: '0xA11413086e163e41901bb81fdc5617c975Fa5a1A', + vetDomainAvatarUrl: 'https://vet.domains/api/avatar', }; export default config; diff --git a/packages/vechain-kit/src/config/solo.ts b/packages/vechain-kit/src/config/solo.ts index 54bc47f..bfffe74 100644 --- a/packages/vechain-kit/src/config/solo.ts +++ b/packages/vechain-kit/src/config/solo.ts @@ -41,6 +41,7 @@ const config: AppConfig = { vetDomainsReverseRegistrarAddress: '0x5c970901a587BA3932C835D4ae5FAE2BEa7e78Bc', vnsResolverAddress: '0x0000000000000000000000000000000000000000', + vetDomainAvatarUrl: 'https://testnet.vet.domains/api/avatar', indexerUrl: 'https://b3tr.testnet.vechain.org/api/v1', b3trIndexerUrl: 'https://b3tr.testnet.vechain.org/api/v1', graphQlIndexerUrl: 'https://graph.vet/subgraphs/name/vns', diff --git a/packages/vechain-kit/src/config/testnet.ts b/packages/vechain-kit/src/config/testnet.ts index 96fccad..c1a5a6e 100644 --- a/packages/vechain-kit/src/config/testnet.ts +++ b/packages/vechain-kit/src/config/testnet.ts @@ -41,6 +41,7 @@ const config: AppConfig = { vetDomainsReverseRegistrarAddress: '0x6878f1aD5e3015310CfE5B38d7B7071C5D8818Ca', vnsResolverAddress: '0xc403b8EA53F707d7d4de095f0A20bC491Cf2bc94', + vetDomainAvatarUrl: 'https://testnet.vet.domains/api/avatar', indexerUrl: 'https://indexer.testnet.vechain.org/api/v1', b3trIndexerUrl: 'https://b3tr.testnet.vechain.org/api/v1', graphQlIndexerUrl: 'https://graph.vet/subgraphs/name/vns', diff --git a/packages/vechain-kit/src/hooks/api/ipfs/index.ts b/packages/vechain-kit/src/hooks/api/ipfs/index.ts index 4176e61..c4a3eae 100644 --- a/packages/vechain-kit/src/hooks/api/ipfs/index.ts +++ b/packages/vechain-kit/src/hooks/api/ipfs/index.ts @@ -1,3 +1,5 @@ -export * from "./useIpfsMetadata" -export * from "./useIpfsImage" -export * from "./useIpfsMetadatas" +export * from './useIpfsMetadata'; +export * from './useIpfsImage'; +export * from './useIpfsMetadatas'; +export * from './useUploadImages'; +export * from './useSingleImageUpload'; diff --git a/packages/vechain-kit/src/hooks/api/ipfs/useIpfsImage.ts b/packages/vechain-kit/src/hooks/api/ipfs/useIpfsImage.ts index a2e0824..ecee575 100644 --- a/packages/vechain-kit/src/hooks/api/ipfs/useIpfsImage.ts +++ b/packages/vechain-kit/src/hooks/api/ipfs/useIpfsImage.ts @@ -23,7 +23,7 @@ export const getIpfsImage = async ( ): Promise => { if (!uri) throw new Error('IPFS URI is required'); - const response = await fetch(convertUriToUrl(uri, networkType)); + const response = await fetch(convertUriToUrl(uri, networkType) ?? ''); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); diff --git a/packages/vechain-kit/src/hooks/api/ipfs/useIpfsMetadata.ts b/packages/vechain-kit/src/hooks/api/ipfs/useIpfsMetadata.ts index 411bb57..dfe57bc 100644 --- a/packages/vechain-kit/src/hooks/api/ipfs/useIpfsMetadata.ts +++ b/packages/vechain-kit/src/hooks/api/ipfs/useIpfsMetadata.ts @@ -18,6 +18,8 @@ export const getIpfsMetadata = async ( ): Promise => { if (!uri) throw new Error('No URI provided'); const newUri = convertUriToUrl(uri, networkType); + if (!newUri) throw new Error('Invalid URI'); + const response = await fetch(newUri); const data = await response.text(); diff --git a/packages/vechain-kit/src/hooks/api/ipfs/useSingleImageUpload.ts b/packages/vechain-kit/src/hooks/api/ipfs/useSingleImageUpload.ts new file mode 100644 index 0000000..68df1fc --- /dev/null +++ b/packages/vechain-kit/src/hooks/api/ipfs/useSingleImageUpload.ts @@ -0,0 +1,59 @@ +import { useState, useCallback, useEffect } from 'react'; +import imageCompression from 'browser-image-compression'; +import { imageCompressionOptions, UploadedImage } from './useUploadImages'; + +type Props = { + compressImage?: boolean; + defaultImage?: UploadedImage; +}; +/** + * Hook to handle image uploads and compressions in a dropzone + * @param param0 compressImags: boolean to indicate if the image should be compressed (default: true) + * @param param1 defaultImage: default image to be displayed + * @returns uploaded image, setUploadedImage: function to set the uploaded image, onDrop: function to handle the drop event + */ + +export const useSingleImageUpload = ({ + compressImage, + defaultImage, +}: Props) => { + const [uploadedImage, setUploadedImage] = useState< + UploadedImage | undefined + >(defaultImage); + + useEffect(() => { + if (defaultImage) { + setUploadedImage(defaultImage); + } + }, [defaultImage]); + + const onRemove = useCallback(() => setUploadedImage(undefined), []); + + const onUpload = useCallback( + async (acceptedFile: File) => { + let parsedFile = acceptedFile; + if (compressImage) { + parsedFile = await imageCompression( + parsedFile, + imageCompressionOptions, + ); + } + + const image: UploadedImage = { + file: parsedFile, + image: URL.createObjectURL(parsedFile), + }; + + setUploadedImage(image); + return image; + }, + [compressImage], + ); + + return { + uploadedImage, + setUploadedImage, + onUpload, + onRemove, + }; +}; diff --git a/packages/vechain-kit/src/hooks/api/ipfs/useUploadImages.ts b/packages/vechain-kit/src/hooks/api/ipfs/useUploadImages.ts new file mode 100644 index 0000000..7c10183 --- /dev/null +++ b/packages/vechain-kit/src/hooks/api/ipfs/useUploadImages.ts @@ -0,0 +1,107 @@ +'use client'; +import { useState, useCallback, useEffect } from 'react'; +import imageCompression, { + Options as CompressOptions, +} from 'browser-image-compression'; + +export const imageCompressionOptions: CompressOptions = { + maxSizeMB: 0.4, + maxWidthOrHeight: 1920, + useWebWorker: true, +}; + +export const compressImages = async (images: UploadedImage[]) => { + const compressedImages: File[] = []; + try { + for (const image of images) { + const parsedFile = await imageCompression( + image.file, + imageCompressionOptions, + ); + + compressedImages.push(parsedFile); + } + return compressedImages; + } catch (e) { + console.error('compress error', e); + throw e; + } +}; + +type Props = { + compressImages?: boolean; + defaultImages?: UploadedImage[]; +}; +/** + * Hook to handle image uploads and compressions in a dropzone + * @param param0 compressImages: boolean to indicate if the images should be compressed (default: true) + * @returns uploadedImages: array of uploaded images, setUploadedImages: function to set the uploaded images, onDrop: function to handle the drop event + */ + +export type UploadedImage = { + file: File; + image: string; +}; +export const useUploadImages = ({ compressImages, defaultImages }: Props) => { + const [uploadedImages, setUploadedImages] = useState( + defaultImages ?? [], + ); + + useEffect(() => { + if (defaultImages) { + setUploadedImages(defaultImages); + } + }, [defaultImages]); + + const [invalidDateError, setInvalidDateError] = useState([]); + + const onRemove = useCallback( + (index: number) => + setUploadedImages((s) => s.filter((_, i) => i !== index)), + [], + ); + + const onUpload = useCallback( + async (acceptedFiles: File[], keepCurrent = true) => { + setInvalidDateError([]); + + const parsedUploads: UploadedImage[] = []; + for (const file of acceptedFiles) { + let parsedFile = file; + if (compressImages) { + parsedFile = await imageCompression( + file, + imageCompressionOptions, + ); + } + + const image: UploadedImage = { + file: parsedFile, + image: URL.createObjectURL(file), + }; + parsedUploads.push(image); + } + + setUploadedImages((s) => [ + ...parsedUploads, + ...(!keepCurrent + ? [] + : s.filter( + (f) => + !parsedUploads.some( + (p) => p.file.name === f.file.name, + ), + )), + ]); + }, + [compressImages], + ); + + return { + uploadedImages, + setUploadedImages, + onUpload, + onRemove, + invalidDateError, + }; +}; diff --git a/packages/vechain-kit/src/hooks/api/vebetterdao/xApps/getXAppMetadata.ts b/packages/vechain-kit/src/hooks/api/vebetterdao/xApps/getXAppMetadata.ts index 1d5e679..cc03c00 100644 --- a/packages/vechain-kit/src/hooks/api/vebetterdao/xApps/getXAppMetadata.ts +++ b/packages/vechain-kit/src/hooks/api/vebetterdao/xApps/getXAppMetadata.ts @@ -41,7 +41,10 @@ export const getXAppMetadata = async ( uri: string, networkType: NETWORK_TYPE, ): Promise => { - const response = await fetch(convertUriToUrl(uri, networkType)); + const url = convertUriToUrl(uri, networkType); + if (!url) return undefined; + + const response = await fetch(url); if (!response.ok) { return undefined; diff --git a/packages/vechain-kit/src/hooks/api/vetDomains/index.ts b/packages/vechain-kit/src/hooks/api/vetDomains/index.ts index 810a3a6..32839b0 100644 --- a/packages/vechain-kit/src/hooks/api/vetDomains/index.ts +++ b/packages/vechain-kit/src/hooks/api/vetDomains/index.ts @@ -3,3 +3,5 @@ export * from './useEnsRecordExists'; export * from './useClaimVeWorldSubdomain'; export * from './useIsDomainProtected'; export * from './useGetDomainsOfAddress'; +export * from './useGetAvatar'; +export * from './useUpdateAvatarRecord'; diff --git a/packages/vechain-kit/src/hooks/api/vetDomains/useGetAvatar.ts b/packages/vechain-kit/src/hooks/api/vetDomains/useGetAvatar.ts new file mode 100644 index 0000000..da158fb --- /dev/null +++ b/packages/vechain-kit/src/hooks/api/vetDomains/useGetAvatar.ts @@ -0,0 +1,123 @@ +import { useQuery } from '@tanstack/react-query'; +import { useVeChainKitConfig } from '@/providers'; +import { Interface, namehash } from 'ethers'; +import { NETWORK_TYPE } from '@/config/network'; +import { getConfig } from '@/config'; + +const nameInterface = new Interface([ + 'function resolver(bytes32 node) returns (address resolverAddress)', + 'function text(bytes32 node, string key) returns (string avatar)', +]); + +/** + * Fetches the avatar for a given VET domain name + * @param networkType - The network type ('main' or 'test') + * @param nodeUrl - The node URL + * @param name - The VET domain name + * @returns The avatar URL from the response + */ +export const getAvatar = async ( + networkType: NETWORK_TYPE, + nodeUrl: string, + name: string, +): Promise => { + if (!name) throw new Error('Name is required'); + + const node = namehash(name); + + try { + // Get resolver address + const resolverResponse = await fetch(`${nodeUrl}/accounts/*`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + clauses: [ + { + to: getConfig(networkType).vetDomainsContractAddress, + data: nameInterface.encodeFunctionData('resolver', [ + node, + ]), + }, + ], + }), + }); + + const [{ data: resolverData, reverted: noResolver }] = + await resolverResponse.json(); + + if (noResolver) { + return null; + } + + const { resolverAddress } = nameInterface.decodeFunctionResult( + 'resolver', + resolverData, + ); + + // Get avatar + const avatarResponse = await fetch(`${nodeUrl}/accounts/*`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + clauses: [ + { + to: resolverAddress, + data: nameInterface.encodeFunctionData('text', [ + node, + 'avatar', + ]), + }, + ], + }), + }); + + const [{ data: lookupData, reverted: noLookup }] = + await avatarResponse.json(); + + if (noLookup) { + return null; + } + + const { avatar } = nameInterface.decodeFunctionResult( + 'text', + lookupData, + ); + + return avatar === '' ? null : avatar; + } catch (error) { + console.error('Error fetching avatar:', error); + throw error; + } +}; + +export const getAvatarQueryKey = (name: string) => [ + 'VECHAIN_KIT', + 'VET_DOMAINS', + 'AVATAR', + name, +]; + +/** + * Hook to fetch the avatar URL for a VET domain name + * @param name - The VET domain name + * @returns The resolved avatar URL + */ +export const useGetAvatar = (name?: string) => { + const { network } = useVeChainKitConfig(); + const nodeUrl = network.nodeUrl ?? getConfig(network.type).nodeUrl; + + const avatarQuery = useQuery({ + queryKey: getAvatarQueryKey(name ?? ''), + queryFn: () => getAvatar(network.type, nodeUrl, name!), + enabled: !!name && !!nodeUrl && !!network.type, + retry: 3, + retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), + staleTime: 5 * 60 * 1000, // 5 minutes + }); + + return avatarQuery; +}; diff --git a/packages/vechain-kit/src/hooks/api/vetDomains/useUpdateAvatarRecord.ts b/packages/vechain-kit/src/hooks/api/vetDomains/useUpdateAvatarRecord.ts new file mode 100644 index 0000000..7bbcd02 --- /dev/null +++ b/packages/vechain-kit/src/hooks/api/vetDomains/useUpdateAvatarRecord.ts @@ -0,0 +1,86 @@ +import { Interface, namehash } from 'ethers'; +import { useVeChainKitConfig } from '@/providers'; +import { getConfig } from '@/config'; +import { useCallback } from 'react'; +import { useSendTransaction } from '@/hooks/transactions/useSendTransaction'; + +const nameInterface = new Interface([ + 'function resolver(bytes32 node) returns (address resolverAddress)', + 'function setText(bytes32 node, string key, string value) external', +]); + +type UseUpdateAvatarRecordProps = { + onSuccess?: () => void; + onError?: () => void; +}; + +export const useUpdateAvatarRecord = ({ + onSuccess, + onError, +}: UseUpdateAvatarRecordProps) => { + const { network } = useVeChainKitConfig(); + const nodeUrl = network.nodeUrl ?? getConfig(network.type).nodeUrl; + const { sendTransaction } = useSendTransaction({ + onTxConfirmed: onSuccess, + onTxFailedOrCancelled: onError, + }); + + const updateAvatar = useCallback( + async (domain: string, ipfsUri: string) => { + if (!domain) throw new Error('Domain is required'); + // Remove the IPFS URI validation to allow empty strings, so we can reset the avatar + // if (!ipfsUri) throw new Error('IPFS URI is required'); + + const node = namehash(domain); + + // Get resolver address using read-only call + const resolverResponse = await fetch(`${nodeUrl}/accounts/*`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + clauses: [ + { + to: getConfig(network.type) + .vetDomainsContractAddress, + data: nameInterface.encodeFunctionData('resolver', [ + node, + ]), + }, + ], + }), + }); + + const [{ data: resolverData, reverted: noResolver }] = + await resolverResponse.json(); + if (noResolver) { + throw new Error('Failed to get resolver address'); + } + + const { resolverAddress } = nameInterface.decodeFunctionResult( + 'resolver', + resolverData, + ); + + // Send transaction to update avatar text record + const setTextClause = { + to: resolverAddress, + data: nameInterface.encodeFunctionData('setText', [ + node, + 'avatar', + ipfsUri, + ]), + value: '0', + comment: 'Update avatar record', + }; + + await sendTransaction([setTextClause]); + }, + [network.type, nodeUrl, sendTransaction], + ); + + return { + updateAvatar, + }; +}; diff --git a/packages/vechain-kit/src/hooks/api/wallet/useWallet.ts b/packages/vechain-kit/src/hooks/api/wallet/useWallet.ts index 0424298..bfaeccf 100644 --- a/packages/vechain-kit/src/hooks/api/wallet/useWallet.ts +++ b/packages/vechain-kit/src/hooks/api/wallet/useWallet.ts @@ -2,17 +2,8 @@ import { useLoginWithOAuth, usePrivy, User } from '@privy-io/react-auth'; import { useWallet as useDappKitWallet } from '@vechain/dapp-kit-react'; -import { - useVechainDomain, - useGetChainId, - useGetNodeUrl, - useContractVersion, -} from '@/hooks'; -import { - compareAddresses, - getPicassoImage, - VECHAIN_PRIVY_APP_ID, -} from '@/utils'; +import { useGetChainId, useGetNodeUrl, useContractVersion } from '@/hooks'; +import { compareAddresses, VECHAIN_PRIVY_APP_ID } from '@/utils'; import { ConnectionSource, SmartAccount, Wallet } from '@/types'; import { useSmartAccount } from '.'; import { useVeChainKitConfig } from '@/providers'; @@ -21,6 +12,7 @@ import { useAccount } from 'wagmi'; import { usePrivyCrossAppSdk } from '@/providers/PrivyCrossAppProvider'; import { useCallback, useEffect, useState } from 'react'; import { useCrossAppConnectionCache } from '@/hooks'; +import { useWalletMetadata } from './useWalletMetadata'; export type UseWalletReturnType = { // This will be the smart account if connected with privy, otherwise it will be wallet connected with dappkit @@ -32,15 +24,6 @@ export type UseWalletReturnType = { // Every user connected with privy has one smartAccount: SmartAccount; - // When user connects with a wallet - dappKitWallet?: Wallet; - - // wallet created by the social login provider - embeddedWallet?: Wallet; - - // Wallet of the user connected with a cross app provider - crossAppWallet?: Wallet; - // Privy user if user is connected with privy privyUser: User | null; @@ -128,21 +111,23 @@ export const useWallet = (): UseWalletReturnType => { isConnectedWithSocialLogin || isConnectedWithCrossApp; - setIsConnected(isNowConnected); + if (isConnected !== isNowConnected) { + setIsConnected(isNowConnected); - // Force re-render of dependent components - if (!isNowConnected) { - // Clear any cached wallet data - clearConnectionCache(); - // Dispatch event to trigger re-renders - window.dispatchEvent(new Event('wallet_disconnected')); + // Only clear cache and dispatch event when disconnecting + if (!isNowConnected) { + // Clear any cached wallet data + clearConnectionCache(); + // Dispatch event to trigger re-renders + window.dispatchEvent(new Event('wallet_disconnected')); + } } }, [ isConnectedWithDappKit, isConnectedWithSocialLogin, isConnectedWithCrossApp, clearConnectionCache, - connectionSource, + isConnected, ]); // Get embedded wallet @@ -163,38 +148,42 @@ export const useWallet = (): UseWalletReturnType => { ? dappKitAccount : smartAccount?.address; - const accountDomain = useVechainDomain(activeAddress ?? '').data?.domain; + const activeMetadata = useWalletMetadata(activeAddress, network.type); + const connectedMetadata = useWalletMetadata( + connectedWalletAddress, + network.type, + ); + const smartAccountMetadata = useWalletMetadata( + smartAccount?.address, + network.type, + ); + const account = activeAddress ? { address: activeAddress, - domain: accountDomain, - image: getPicassoImage(activeAddress), + domain: activeMetadata.domain, + image: activeMetadata.image, } : null; - const connectedWalletDomain = useVechainDomain(connectedWalletAddress ?? '') - .data?.domain; const connectedWallet = connectedWalletAddress ? { address: connectedWalletAddress, - domain: connectedWalletDomain, - image: getPicassoImage(connectedWalletAddress), + domain: connectedMetadata.domain, + image: connectedMetadata.image, } : null; - //TODO: add isLoading for each domain - // Use cached domain lookups for each address - const walletDomain = useVechainDomain(dappKitAccount ?? '').data?.domain; - const smartAccountDomain = useVechainDomain(smartAccount?.address ?? '') - .data?.domain; - const embeddedWalletDomain = useVechainDomain(privyEmbeddedWallet ?? '') - .data?.domain; - const crossAppAccountDomain = useVechainDomain(crossAppAddress ?? '').data - ?.domain; + // Get smart account version const { data: smartAccountVersion } = useContractVersion( smartAccount?.address ?? '', ); + const hasActiveSmartAccount = + !!smartAccount?.address && + !!account?.address && + compareAddresses(smartAccount?.address, account?.address); + // Modify the disconnect function to ensure state updates const disconnect = useCallback(async () => { try { @@ -225,43 +214,16 @@ export const useWallet = (): UseWalletReturnType => { clearConnectionCache, ]); - const hasActiveSmartAccount = - !!smartAccount?.address && - !!account?.address && - compareAddresses(smartAccount?.address, account?.address); - return { account, smartAccount: { address: smartAccount?.address ?? '', - domain: smartAccountDomain, - image: getPicassoImage(smartAccount?.address ?? ''), + domain: smartAccountMetadata.domain, + image: smartAccountMetadata.image, isDeployed: smartAccount?.isDeployed ?? false, isActive: hasActiveSmartAccount, version: smartAccountVersion ?? null, }, - dappKitWallet: isConnectedWithDappKit - ? { - address: dappKitAccount, - domain: walletDomain, - image: getPicassoImage(dappKitAccount ?? ''), - } - : undefined, - embeddedWallet: privyEmbeddedWallet - ? { - address: privyEmbeddedWallet, - domain: embeddedWalletDomain, - image: getPicassoImage(privyEmbeddedWallet), - } - : undefined, - crossAppWallet: crossAppAddress - ? { - address: crossAppAddress, - domain: crossAppAccountDomain, - image: getPicassoImage(crossAppAddress), - } - : undefined, - connectedWallet, privyUser: user, connection: { diff --git a/packages/vechain-kit/src/hooks/api/wallet/useWalletMetadata.ts b/packages/vechain-kit/src/hooks/api/wallet/useWalletMetadata.ts new file mode 100644 index 0000000..f250e64 --- /dev/null +++ b/packages/vechain-kit/src/hooks/api/wallet/useWalletMetadata.ts @@ -0,0 +1,17 @@ +import { NETWORK_TYPE } from '@/config/network'; +import { useGetAvatar, useVechainDomain } from '../vetDomains'; +import { convertUriToUrl, getPicassoImage } from '@/utils'; + +export const useWalletMetadata = ( + address: string | null | undefined, + networkType: NETWORK_TYPE, +) => { + const { data: domain } = useVechainDomain(address ?? ''); + const { data: avatar } = useGetAvatar(domain?.domain); + const avatarUrl = convertUriToUrl(avatar ?? '', networkType); + + return { + domain: domain?.domain, + image: avatarUrl ?? getPicassoImage(address ?? ''), + }; +}; diff --git a/packages/vechain-kit/src/hooks/modals/index.ts b/packages/vechain-kit/src/hooks/modals/index.ts index bd917e6..ca9c953 100644 --- a/packages/vechain-kit/src/hooks/modals/index.ts +++ b/packages/vechain-kit/src/hooks/modals/index.ts @@ -9,3 +9,4 @@ export * from './useEmbeddedWalletSettingsModal'; export * from './useExploreEcosystemModal'; export * from './useNotificationsModal'; export * from './useFAQModal'; +export * from './useAccountCustomizationModal'; diff --git a/packages/vechain-kit/src/hooks/modals/useAccountCustomizationModal.tsx b/packages/vechain-kit/src/hooks/modals/useAccountCustomizationModal.tsx new file mode 100644 index 0000000..045b078 --- /dev/null +++ b/packages/vechain-kit/src/hooks/modals/useAccountCustomizationModal.tsx @@ -0,0 +1,32 @@ +import { useVeChainKitConfig } from '@/providers'; +import { ReactNode } from 'react'; + +export const useAccountCustomizationModal = () => { + const { + openAccountModal, + closeAccountModal, + isAccountModalOpen, + setAccountModalContent, + } = useVeChainKitConfig(); + + const open = () => { + setAccountModalContent('account-customization'); + openAccountModal(); + }; + + const close = () => { + closeAccountModal(); + }; + + return { + open, + close, + isOpen: isAccountModalOpen, + }; +}; + +export const AccountCustomizationModalProvider = ({ + children, +}: { + children: ReactNode; +}) => <>{children}; diff --git a/packages/vechain-kit/src/languages/en.json b/packages/vechain-kit/src/languages/en.json index 81635c2..fc336e2 100644 --- a/packages/vechain-kit/src/languages/en.json +++ b/packages/vechain-kit/src/languages/en.json @@ -308,5 +308,29 @@ "{{element}} website": "{{element}} website", "{{name}}": "{{name}}", "Loading your domains...": "Loading your domains...", - "Your existing domains": "Your existing domains" + "Your existing domains": "Your existing domains", + "Customize account": "Customize account", + "Customize Account": "Customize Account", + "Customize your account with a nickname and a picture to easily identify it.": "Customize your account with a nickname and a picture to easily identify it.", + "Choose a unique .vet domain name for your account.": "Choose a unique .vet domain name for your account.", + "Profile Picture": "Profile Picture", + "Customize your account profile picture": "Customize your account profile picture", + "Profile Picture Updated": "Profile Picture Updated", + "Your profile picture has been successfully updated.": "Your profile picture has been successfully updated.", + "Back to Settings": "Back to Settings", + "Upload profile image": "Upload profile image", + "You can change your profile image only after you setup your domain name.": "You can change your profile image only after you setup your domain name.", + "Customize your account by adding a profile image, which will be displayed on the apps you use.": "Customize your account by adding a profile image, which will be displayed on the apps you use.", + "Uploading image": "Uploading image", + "Setting image": "Setting image", + "Your new profile image is set correctly": "Your new profile image is set correctly", + "Update profile image": "Update profile image", + "Customize your account with a unique .vet domain name and profile image to enhance your identity across VeChain applications.": "Customize your account with a unique .vet domain name and profile image to enhance your identity across VeChain applications.", + "Your customizations are linked to your .vet domain name, making them portable across different applications.": "Your customizations are linked to your .vet domain name, making them portable across different applications.", + "To get started with customization, first secure your .vet domain name. Once you have a domain, you can add a profile image that will be visible wherever you use your account.": "To get started with customization, first secure your .vet domain name. Once you have a domain, you can add a profile image that will be visible wherever you use your account.", + "Changing your domain name will update also your profile image.": "Changing your domain name will update also your profile image.", + "Manage passkey login": "Manage passkey login", + "This is your main wallet, created by {{element}} and secured by Privy. This wallet is the owner of your smart account, which is used as your identity and as a gateway for your blockchain interactions. Please be sure to keep it safe and backed up.": "This is your main wallet, created by {{element}} and secured by Privy. This wallet is the owner of your smart account, which is used as your identity and as a gateway for your blockchain interactions. Please be sure to keep it safe and backed up.", + "This wallet is the owner of your smart account, which is used as your identity and as a gateway for your blockchain interactions.": "This wallet is the owner of your smart account, which is used as your identity and as a gateway for your blockchain interactions.", + "Handle Passkey Login": "Handle Passkey Login" } diff --git a/packages/vechain-kit/src/utils/ipfs.ts b/packages/vechain-kit/src/utils/ipfs.ts index 91f3016..f515c8b 100644 --- a/packages/vechain-kit/src/utils/ipfs.ts +++ b/packages/vechain-kit/src/utils/ipfs.ts @@ -1,3 +1,6 @@ +import { getConfig } from '@/config'; +import { NETWORK_TYPE } from '@/config/network'; + /** * Validate IPFS URI strings. An example of a valid IPFS URI is: * - ipfs://QmfSTia1TJUiKQ2fyW9NTPzEKNdjMGzbUgrC3QPSTpkum6/406.json @@ -22,3 +25,38 @@ export const validateIpfsUri = (uri: string): boolean => { export function toIPFSURL(cid: string, fileName?: string): string { return `ipfs://${cid}/${fileName ?? ''}`; } + +/** + * Uploads a blob to IPFS. + * @param blob The Blob object to upload. + * @param filename A name for the file in the FormData payload. + * @param networkType The network type to use for the IPFS pinning service. + * @returns The IPFS hash of the uploaded blob. + */ +export async function uploadBlobToIPFS( + blob: Blob, + filename: string, + networkType: NETWORK_TYPE, +): Promise { + try { + const form = new FormData(); + form.append('file', blob, filename); + const response = await fetch( + getConfig(networkType).ipfsPinningService, + { + method: 'POST', + body: form, + }, + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data.IpfsHash; + } catch (error) { + console.error('Error uploading blob:', error); + throw new Error('Failed to upload blob to IPFS'); + } +} diff --git a/packages/vechain-kit/src/utils/uri.ts b/packages/vechain-kit/src/utils/uri.ts index c73eea2..be5b318 100644 --- a/packages/vechain-kit/src/utils/uri.ts +++ b/packages/vechain-kit/src/utils/uri.ts @@ -14,7 +14,8 @@ export const convertUriToUrl = (uri: string, networkType: NETWORK_TYPE) => { if (uri.startsWith('data:')) return uri; const splitUri = uri?.split('://'); - if (splitUri.length !== 2) throw new Error(`Invalid URI ${uri}`); + if (splitUri.length !== 2) return; + // if (splitUri.length !== 2) throw new Error(`Invalid URI ${uri}`); const protocol = splitUri?.[0]?.trim(); const uriWithoutProtocol = splitUri[1]; diff --git a/yarn.lock b/yarn.lock index 1b87fab..4368d88 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6296,6 +6296,13 @@ brorand@^1.0.1, brorand@^1.1.0: resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" integrity sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w== +browser-image-compression@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/browser-image-compression/-/browser-image-compression-2.0.2.tgz#4d5ef8882e9e471d6d923715ceb9034499d14eaa" + integrity sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw== + dependencies: + uzip "0.20201231.0" + browserify-aes@^1.0.4, browserify-aes@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.2.0.tgz#326734642f403dabc3003209853bb70ad428ef48" @@ -13087,6 +13094,11 @@ uuid@^8.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== +uzip@0.20201231.0: + version "0.20201231.0" + resolved "https://registry.yarnpkg.com/uzip/-/uzip-0.20201231.0.tgz#9e64b065b9a8ebf26eb7583fe8e77e1d9a15ed14" + integrity sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng== + v8-compile-cache-lib@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" @@ -13156,9 +13168,9 @@ viem@^2.1.1: ws "8.17.1" viem@^2.21.14: - version "2.22.13" - resolved "https://registry.yarnpkg.com/viem/-/viem-2.22.13.tgz#518c3286864cfc25fd8f028eee5a07078cc99349" - integrity sha512-MaQKY5DUQ5SnZJPMytp5nTgvRu7N3wzvBhY31/9VT4lxDZAcQolqYEK3EqP+cdAD8jl0YmGuoJlfW9D1crqlGg== + version "2.22.19" + resolved "https://registry.yarnpkg.com/viem/-/viem-2.22.19.tgz#e27d4f9010711e2d55020aef051aa09341900fbf" + integrity sha512-aGR/NUHaboQ/HoS86wYfJWbXt6aewjhp2OCO2uczCrusgcwXO/qC0l36AcFVw2dkOPBEhIG5oXMEso97L+xHmA== dependencies: "@noble/curves" "1.8.1" "@noble/hashes" "1.7.1"