diff --git a/package-lock.json b/package-lock.json index 65f4c0097a..2db412da25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "js-big-decimal": "^1.3.12", "lodash.camelcase": "^4.3.0", "lodash.debounce": "^4.0.8", + "lodash.groupby": "^4.6.0", "lodash.isequal": "^4.5.0", "moralis": "^2.26.3", "react": "^18.2.0", @@ -75,6 +76,7 @@ "@testing-library/react": "^14.2.1", "@types/lodash.camelcase": "^4.3.9", "@types/lodash.debounce": "^4.0.9", + "@types/lodash.groupby": "^4.6.9", "@types/lodash.isequal": "^4.5.8", "@types/react": "^18.0.17", "@types/react-dom": "^18.0.6", @@ -11815,6 +11817,15 @@ "@types/lodash": "*" } }, + "node_modules/@types/lodash.groupby": { + "version": "4.6.9", + "resolved": "https://registry.npmjs.org/@types/lodash.groupby/-/lodash.groupby-4.6.9.tgz", + "integrity": "sha512-z2xtCX2ko7GrqORnnYea4+ksT7jZNAvaOcLd6mP9M7J09RHvJs06W8BGdQQAX8ARef09VQLdeRilSOcfHlDQJQ==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/lodash.isequal": { "version": "4.5.8", "resolved": "https://registry.npmjs.org/@types/lodash.isequal/-/lodash.isequal-4.5.8.tgz", @@ -20993,6 +21004,11 @@ "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==" }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==" + }, "node_modules/lodash.isequal": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", diff --git a/package.json b/package.json index 028a58daa2..f354e9b249 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "js-big-decimal": "^1.3.12", "lodash.camelcase": "^4.3.0", "lodash.debounce": "^4.0.8", + "lodash.groupby": "^4.6.0", "lodash.isequal": "^4.5.0", "moralis": "^2.26.3", "react": "^18.2.0", @@ -100,6 +101,7 @@ "@testing-library/react": "^14.2.1", "@types/lodash.camelcase": "^4.3.9", "@types/lodash.debounce": "^4.0.9", + "@types/lodash.groupby": "^4.6.9", "@types/lodash.isequal": "^4.5.8", "@types/react": "^18.0.17", "@types/react-dom": "^18.0.6", diff --git a/public/images/coin-icon-default.svg b/public/images/coin-icon-default.svg index 6f523732dd..bd636762bf 100644 --- a/public/images/coin-icon-default.svg +++ b/public/images/coin-icon-default.svg @@ -1,3 +1,5 @@ - - + + + + diff --git a/src/components/pages/Roles/forms/RoleFormAssetSelector.tsx b/src/components/pages/Roles/forms/RoleFormAssetSelector.tsx index ea60169d16..da0a122d9a 100644 --- a/src/components/pages/Roles/forms/RoleFormAssetSelector.tsx +++ b/src/components/pages/Roles/forms/RoleFormAssetSelector.tsx @@ -1,31 +1,33 @@ import { - FormControl, - Menu, - MenuButton, Button, Flex, - Text, + FormControl, Icon, - MenuList, - Divider, - MenuItem, Image, + Menu, + MenuButton, + MenuItem, + MenuList, + Show, + Text, } from '@chakra-ui/react'; import { CaretDown, CheckCircle } from '@phosphor-icons/react'; -import { useFormikContext, Field, FieldProps } from 'formik'; +import { Field, FieldInputProps, FieldProps, useFormikContext } from 'formik'; import { useTranslation } from 'react-i18next'; import { CARD_SHADOW } from '../../../../constants/common'; import { useFractal } from '../../../../providers/App/AppProvider'; import { BigIntValuePair } from '../../../../types'; -import { formatUSD } from '../../../../utils'; +import { formatCoin, formatUSD } from '../../../../utils'; import { MOCK_MORALIS_ETH_ADDRESS } from '../../../../utils/address'; +import DraggableDrawer from '../../../ui/containers/DraggableDrawer'; import { BigIntInput } from '../../../ui/forms/BigIntInput'; import LabelWrapper from '../../../ui/forms/LabelWrapper'; +import Divider from '../../../ui/utils/Divider'; import { EaseOutComponent } from '../../../ui/utils/EaseOutComponent'; import { RoleFormValues } from '../types'; -export function AssetSelector({ formIndex }: { formIndex: number }) { - const { t } = useTranslation(['roles', 'treasury', 'modals']); +function AssetsList({ field, formIndex }: { field: FieldInputProps; formIndex: number }) { + const { t } = useTranslation('roles'); const { treasury: { assetsFungible }, } = useFractal(); @@ -36,6 +38,114 @@ export function AssetSelector({ formIndex }: { formIndex: number }) { ); const { values, setFieldValue } = useFormikContext(); const selectedAsset = values.roleEditing?.payments?.[formIndex]?.asset; + + if (fungibleAssetsWithBalance.length === 0) { + return ( + + + {t('emptyRolesAssets')} + + + ); + } + + return ( + <> + {fungibleAssetsWithBalance.map((asset, index) => { + const isSelected = selectedAsset?.address === asset.tokenAddress; + return ( + { + setFieldValue(field.name, { + address: fungibleAssetsWithBalance[index].tokenAddress, + symbol: fungibleAssetsWithBalance[index].symbol, + logo: fungibleAssetsWithBalance[index].logo, + balance: fungibleAssetsWithBalance[index].balance, + balanceFormatted: fungibleAssetsWithBalance[index].balanceFormatted, + decimals: fungibleAssetsWithBalance[index].decimals, + }); + }} + > + + + + + {asset.symbol} + + + + {formatCoin(asset.balance, true, asset.decimals, asset.symbol, true)} + + {asset.usdValue && ( + <> + + {'•'} + + + {formatUSD(asset.usdValue)} + + + )} + + + + {isSelected && ( + + )} + + ); + })} + + ); +} + +export function AssetSelector({ formIndex }: { formIndex: number }) { + const { t } = useTranslation(['roles', 'treasury', 'modals']); + const { values, setFieldValue } = useFormikContext(); + const selectedAsset = values.roleEditing?.payments?.[formIndex]?.asset; + return ( <> @@ -45,161 +155,121 @@ export function AssetSelector({ formIndex }: { formIndex: number }) { placement="bottom-end" offset={[0, 8]} > - <> - ( + <> + - - - - {selectedAsset?.symbol ?? t('selectLabel', { ns: 'modals' })} - + + + {selectedAsset?.symbol ?? t('selectLabel', { ns: 'modals' })} + + + - - - - - - + + {}} + onClose={onClose} + closeOnOverlayClick + headerContent={ + + {t('titleAssets', { ns: 'treasury' })} + + + } + > + + + + + + + - {t('titleAssets', { ns: 'treasury' })} - - - {fungibleAssetsWithBalance.map((asset, index) => { - const isSelected = selectedAsset?.address === asset.tokenAddress; - return ( - { - setFieldValue(field.name, { - address: fungibleAssetsWithBalance[index].tokenAddress, - symbol: fungibleAssetsWithBalance[index].symbol, - logo: fungibleAssetsWithBalance[index].logo, - balance: fungibleAssetsWithBalance[index].balance, - balanceFormatted: fungibleAssetsWithBalance[index].balanceFormatted, - decimals: fungibleAssetsWithBalance[index].decimals, - }); - }} + + - - - - - {asset.symbol} - - - - {asset.balanceFormatted} - - - {asset.symbol} - - {asset.usdValue && ( - <> - - {'•'} - - - {formatUSD(asset.usdValue)} - - - )} - - - - {isSelected && ( - - )} - - ); - })} - - - + {t('titleAssets', { ns: 'treasury' })} + + + + + + + + )} )} diff --git a/src/components/ui/containers/EmptyBox.tsx b/src/components/ui/containers/EmptyBox.tsx index 2810a1c5ca..f3d641f906 100644 --- a/src/components/ui/containers/EmptyBox.tsx +++ b/src/components/ui/containers/EmptyBox.tsx @@ -2,14 +2,7 @@ import { Flex, Text } from '@chakra-ui/react'; import { ReactNode } from 'react'; import { ActivityBox } from './ActivityBox'; -export function EmptyBox({ - emptyText, - children, -}: { - emptyText: string; - children?: ReactNode; - m?: string | number; -}) { +export function EmptyBox({ emptyText, children }: { emptyText: string; children?: ReactNode }) { return ( { - const exponent = 10n ** BigInt(asset.decimals); - const totalAmountInTokenDecimals = BigInt(totalAmount) * exponent; - + ({ totalAmount, recipient, schedule, cliff }: LinearStreamInputs) => { const calculateDuration = (abstractSchedule: StreamSchedule) => { let duration = 0; const relativeSchedule = abstractSchedule as StreamRelativeSchedule; @@ -96,17 +89,15 @@ export default function useCreateSablierStream() { if (!streamDuration) { throw new Error('Stream duration can not be 0'); } - - const tokenCalldata = prepareStreamTokenCallData(totalAmountInTokenDecimals); - const basicStreamData = prepareBasicStreamData(recipient, totalAmountInTokenDecimals); + const basicStreamData = prepareBasicStreamData(recipient, totalAmount); const assembledStream = { ...basicStreamData, durations: { cliff: cliffDuration, total: streamDuration + cliffDuration }, // Total duration has to include cliff duration }; - return { tokenCalldata, assembledStream }; + return assembledStream; }, - [prepareBasicStreamData, prepareStreamTokenCallData], + [prepareBasicStreamData], ); const prepareFlushStreamTx = useCallback((stream: BaseSablierStream, to: Address) => { @@ -155,46 +146,63 @@ export default function useCreateSablierStream() { const preparedStreamCreationTransactions: { calldata: Hex; targetAddress: Address }[] = []; const preparedTokenApprovalsTransactions: { calldata: Hex; targetAddress: Address }[] = []; - linearStreams.forEach((streamData, index) => { - const recipient = recipients[index]; - const tokenAddress = streamData.asset.address; - const schedule = - streamData.scheduleType === 'duration' && streamData.scheduleDuration - ? streamData.scheduleDuration.duration - : { - startDate: streamData.scheduleFixedDate!.startDate.getTime(), - endDate: streamData.scheduleFixedDate!.endDate.getTime(), - }; - const cliff = - streamData.scheduleType === 'duration' - ? streamData.scheduleDuration!.cliffDuration - : streamData.scheduleFixedDate?.cliffDate !== undefined - ? { - startDate: streamData.scheduleFixedDate!.cliffDate.getTime(), - endDate: streamData.scheduleFixedDate!.cliffDate.getTime(), - } - : undefined; - - // @todo - Smarter way would be to batch token approvals and streams creation, and not just build single approval + creation transactions for each stream - const { tokenCalldata, assembledStream } = prepareLinearStream({ - recipient, - ...streamData, - totalAmount: streamData.amount.value, - asset: streamData.asset, - schedule, - cliff, + const groupedStreams = groupBy(linearStreams, 'asset.address'); + Object.keys(groupedStreams).forEach(assetAddress => { + const assembledStreams: ReturnType[] = []; + const streams = groupedStreams[assetAddress]; + const tokenAddress = getAddress(assetAddress); + let totalStreamsAmount = 0n; + + streams.forEach((streamData, index) => { + if (!streamData.amount.bigintValue || streamData.amount.bigintValue <= 0n) { + console.error( + 'Error creating linear stream - stream amount must be bigger than 0', + streamData, + ); + throw new Error('Stream total amount must be greater than 0'); + } + totalStreamsAmount += streamData.amount.bigintValue; + const recipient = recipients[index]; + const schedule = + streamData.scheduleType === 'duration' && streamData.scheduleDuration + ? streamData.scheduleDuration.duration + : { + startDate: streamData.scheduleFixedDate!.startDate.getTime(), + endDate: streamData.scheduleFixedDate!.endDate.getTime(), + }; + const cliff = + streamData.scheduleType === 'duration' + ? streamData.scheduleDuration!.cliffDuration + : streamData.scheduleFixedDate?.cliffDate !== undefined + ? { + startDate: streamData.scheduleFixedDate!.cliffDate.getTime(), + endDate: streamData.scheduleFixedDate!.cliffDate.getTime(), + } + : undefined; + + const assembledStream = prepareLinearStream({ + recipient, + ...streamData, + totalAmount: streamData.amount.bigintValue, + schedule, + cliff, + }); + assembledStreams.push(assembledStream); }); const sablierBatchCalldata = encodeFunctionData({ abi: SablierV2BatchAbi, - functionName: 'createWithDurationsLL', // Another option would be to use createWithTimestampsLL. Essentially they're doing the same, `WithDurations` just simpler for usage - args: [sablierV2LockupLinear, tokenAddress, [assembledStream]], + functionName: 'createWithDurationsLL', // @dev Another option would be to use `createWithTimestampsLL`. Essentially they're doing the same, `WithDurations` just simpler for usage + args: [sablierV2LockupLinear, tokenAddress, assembledStreams], }); preparedStreamCreationTransactions.push({ calldata: sablierBatchCalldata, targetAddress: sablierV2Batch, }); + + const tokenCalldata = prepareStreamTokenCallData(totalStreamsAmount); + preparedTokenApprovalsTransactions.push({ calldata: tokenCalldata, targetAddress: tokenAddress, @@ -203,7 +211,7 @@ export default function useCreateSablierStream() { return { preparedStreamCreationTransactions, preparedTokenApprovalsTransactions }; }, - [prepareLinearStream, sablierV2Batch, sablierV2LockupLinear], + [prepareLinearStream, prepareStreamTokenCallData, sablierV2Batch, sablierV2LockupLinear], ); return { diff --git a/src/i18n/locales/en/roles.json b/src/i18n/locales/en/roles.json index f574ec7cfe..f3ee922839 100644 --- a/src/i18n/locales/en/roles.json +++ b/src/i18n/locales/en/roles.json @@ -73,5 +73,6 @@ "discardChanges": "Discard", "keepEditing": "Keep Editing", "addPaymentTooltip": "Payments on a per-second basis; powered by Sablier.", - "cliffPaymentTooltip": "Assets continue to accrue but aren't yet claimable here." + "cliffPaymentTooltip": "Assets continue to accrue but aren't yet claimable here.", + "emptyRolesAssets": "No assets available for payments." } diff --git a/src/providers/NetworkConfig/networks/base.ts b/src/providers/NetworkConfig/networks/base.ts index 9632d48d8c..1b9e121ec3 100644 --- a/src/providers/NetworkConfig/networks/base.ts +++ b/src/providers/NetworkConfig/networks/base.ts @@ -15,10 +15,10 @@ import MultisigFreezeVoting from '@fractal-framework/fractal-contracts/deploymen import VotesERC20 from '@fractal-framework/fractal-contracts/deployments/base/VotesERC20.json' assert { type: 'json' }; import VotesERC20Wrapper from '@fractal-framework/fractal-contracts/deployments/base/VotesERC20Wrapper.json' assert { type: 'json' }; import { - getProxyFactoryDeployment, + getCompatibilityFallbackHandlerDeployment, getMultiSendCallOnlyDeployment, + getProxyFactoryDeployment, getSafeL2SingletonDeployment, - getCompatibilityFallbackHandlerDeployment, } from '@safe-global/safe-deployments'; import { base } from 'wagmi/chains'; import { GovernanceType } from '../../../types'; @@ -30,10 +30,10 @@ const baseConfig: NetworkConfig = { order: 10, chain: base, moralisSupported: true, - rpcEndpoint: `https://base-mainnet.g.alchemy.com/v2/${import.meta.env.VITE_APP_ALCHEMY_API_KEY}`, + rpcEndpoint: `https://base-mainnet.g.alchemy.com/v2/${import.meta.env?.VITE_APP_ALCHEMY_API_KEY}`, safeBaseURL: 'https://safe-transaction-base.safe.global', etherscanBaseURL: 'https://basescan.org/', - etherscanAPIUrl: `https://api.basescan.com/api?apikey=${import.meta.env.VITE_APP_ETHERSCAN_BASE_API_KEY}`, + etherscanAPIUrl: `https://api.basescan.com/api?apikey=${import.meta.env?.VITE_APP_ETHERSCAN_BASE_API_KEY}`, addressPrefix: 'base', nativeTokenIcon: '/images/coin-icon-base.svg', subgraph: { diff --git a/src/providers/NetworkConfig/networks/mainnet.ts b/src/providers/NetworkConfig/networks/mainnet.ts index c73ab8bbb4..0dc2f1bd53 100644 --- a/src/providers/NetworkConfig/networks/mainnet.ts +++ b/src/providers/NetworkConfig/networks/mainnet.ts @@ -15,10 +15,10 @@ import MultisigFreezeVoting from '@fractal-framework/fractal-contracts/deploymen import VotesERC20 from '@fractal-framework/fractal-contracts/deployments/mainnet/VotesERC20.json' assert { type: 'json' }; import VotesERC20Wrapper from '@fractal-framework/fractal-contracts/deployments/mainnet/VotesERC20Wrapper.json' assert { type: 'json' }; import { - getProxyFactoryDeployment, + getCompatibilityFallbackHandlerDeployment, getMultiSendCallOnlyDeployment, + getProxyFactoryDeployment, getSafeL2SingletonDeployment, - getCompatibilityFallbackHandlerDeployment, } from '@safe-global/safe-deployments'; import { mainnet } from 'wagmi/chains'; import { GovernanceType } from '../../../types'; @@ -30,10 +30,10 @@ const mainnetConfig: NetworkConfig = { order: 0, chain: mainnet, moralisSupported: true, - rpcEndpoint: `https://eth-mainnet.g.alchemy.com/v2/${import.meta.env.VITE_APP_ALCHEMY_API_KEY}`, + rpcEndpoint: `https://eth-mainnet.g.alchemy.com/v2/${import.meta.env?.VITE_APP_ALCHEMY_API_KEY}`, safeBaseURL: 'https://safe-transaction-mainnet.safe.global', etherscanBaseURL: 'https://etherscan.io', - etherscanAPIUrl: `https://api.etherscan.io/api?apikey=${import.meta.env.VITE_APP_ETHERSCAN_MAINNET_API_KEY}`, + etherscanAPIUrl: `https://api.etherscan.io/api?apikey=${import.meta.env?.VITE_APP_ETHERSCAN_MAINNET_API_KEY}`, addressPrefix: 'eth', nativeTokenIcon: '/images/coin-icon-eth.svg', subgraph: { diff --git a/src/providers/NetworkConfig/networks/optimism.ts b/src/providers/NetworkConfig/networks/optimism.ts index f3cbc5d9d5..b396e7d411 100644 --- a/src/providers/NetworkConfig/networks/optimism.ts +++ b/src/providers/NetworkConfig/networks/optimism.ts @@ -15,10 +15,10 @@ import MultisigFreezeVoting from '@fractal-framework/fractal-contracts/deploymen import VotesERC20 from '@fractal-framework/fractal-contracts/deployments/optimism/VotesERC20.json' assert { type: 'json' }; import VotesERC20Wrapper from '@fractal-framework/fractal-contracts/deployments/optimism/VotesERC20Wrapper.json' assert { type: 'json' }; import { - getProxyFactoryDeployment, + getCompatibilityFallbackHandlerDeployment, getMultiSendCallOnlyDeployment, + getProxyFactoryDeployment, getSafeL2SingletonDeployment, - getCompatibilityFallbackHandlerDeployment, } from '@safe-global/safe-deployments'; import { optimism } from 'wagmi/chains'; import { GovernanceType } from '../../../types'; @@ -30,10 +30,10 @@ const optimismConfig: NetworkConfig = { order: 15, chain: optimism, moralisSupported: true, - rpcEndpoint: `https://opt-mainnet.g.alchemy.com/v2/${import.meta.env.VITE_APP_ALCHEMY_API_KEY}`, + rpcEndpoint: `https://opt-mainnet.g.alchemy.com/v2/${import.meta.env?.VITE_APP_ALCHEMY_API_KEY}`, safeBaseURL: 'https://safe-transaction-optimism.safe.global', etherscanBaseURL: 'https://optimistic.etherscan.io/', - etherscanAPIUrl: `https://api-optimistic.etherscan.io/api?apikey=${import.meta.env.VITE_APP_ETHERSCAN_OPTIMISM_API_KEY}`, + etherscanAPIUrl: `https://api-optimistic.etherscan.io/api?apikey=${import.meta.env?.VITE_APP_ETHERSCAN_OPTIMISM_API_KEY}`, addressPrefix: 'oeth', nativeTokenIcon: '/images/coin-icon-op.svg', subgraph: { diff --git a/src/providers/NetworkConfig/networks/polygon.ts b/src/providers/NetworkConfig/networks/polygon.ts index a94fe53b4d..7ce226d663 100644 --- a/src/providers/NetworkConfig/networks/polygon.ts +++ b/src/providers/NetworkConfig/networks/polygon.ts @@ -15,10 +15,10 @@ import MultisigFreezeVoting from '@fractal-framework/fractal-contracts/deploymen import VotesERC20 from '@fractal-framework/fractal-contracts/deployments/polygon/VotesERC20.json' assert { type: 'json' }; import VotesERC20Wrapper from '@fractal-framework/fractal-contracts/deployments/polygon/VotesERC20Wrapper.json' assert { type: 'json' }; import { - getProxyFactoryDeployment, + getCompatibilityFallbackHandlerDeployment, getMultiSendCallOnlyDeployment, + getProxyFactoryDeployment, getSafeL2SingletonDeployment, - getCompatibilityFallbackHandlerDeployment, } from '@safe-global/safe-deployments'; import { polygon } from 'wagmi/chains'; import { GovernanceType } from '../../../types'; @@ -30,10 +30,10 @@ const polygonConfig: NetworkConfig = { order: 20, chain: polygon, moralisSupported: true, - rpcEndpoint: `https://polygon-mainnet.g.alchemy.com/v2/${import.meta.env.VITE_APP_ALCHEMY_API_KEY}`, + rpcEndpoint: `https://polygon-mainnet.g.alchemy.com/v2/${import.meta.env?.VITE_APP_ALCHEMY_API_KEY}`, safeBaseURL: 'https://safe-transaction-polygon.safe.global', etherscanBaseURL: 'https://polygonscan.com', - etherscanAPIUrl: `https://api.polygonscan.com/api?apikey=${import.meta.env.VITE_APP_ETHERSCAN_POLYGON_API_KEY}`, + etherscanAPIUrl: `https://api.polygonscan.com/api?apikey=${import.meta.env?.VITE_APP_ETHERSCAN_POLYGON_API_KEY}`, addressPrefix: 'matic', nativeTokenIcon: '/images/coin-icon-pol.svg', subgraph: { diff --git a/src/providers/NetworkConfig/networks/sepolia.ts b/src/providers/NetworkConfig/networks/sepolia.ts index 05fd50c065..29285233d8 100644 --- a/src/providers/NetworkConfig/networks/sepolia.ts +++ b/src/providers/NetworkConfig/networks/sepolia.ts @@ -15,10 +15,10 @@ import MultisigFreezeVoting from '@fractal-framework/fractal-contracts/deploymen import VotesERC20 from '@fractal-framework/fractal-contracts/deployments/sepolia/VotesERC20.json' assert { type: 'json' }; import VotesERC20Wrapper from '@fractal-framework/fractal-contracts/deployments/sepolia/VotesERC20Wrapper.json' assert { type: 'json' }; import { + getCompatibilityFallbackHandlerDeployment, getMultiSendCallOnlyDeployment, getProxyFactoryDeployment, getSafeL2SingletonDeployment, - getCompatibilityFallbackHandlerDeployment, } from '@safe-global/safe-deployments'; import { sepolia } from 'wagmi/chains'; import { GovernanceType } from '../../../types'; @@ -30,10 +30,10 @@ const sepoliaConfig: NetworkConfig = { order: 30, chain: sepolia, moralisSupported: true, - rpcEndpoint: `https://eth-sepolia.g.alchemy.com/v2/${import.meta.env.VITE_APP_ALCHEMY_API_KEY}`, + rpcEndpoint: `https://eth-sepolia.g.alchemy.com/v2/${import.meta.env?.VITE_APP_ALCHEMY_API_KEY}`, safeBaseURL: 'https://safe-transaction-sepolia.safe.global', etherscanBaseURL: 'https://sepolia.etherscan.io', - etherscanAPIUrl: `https://api-sepolia.etherscan.io/api?apikey=${import.meta.env.VITE_APP_ETHERSCAN_SEPOLIA_API_KEY}`, + etherscanAPIUrl: `https://api-sepolia.etherscan.io/api?apikey=${import.meta.env?.VITE_APP_ETHERSCAN_SEPOLIA_API_KEY}`, addressPrefix: 'sep', nativeTokenIcon: '/images/coin-icon-sep.svg', subgraph: {