From 04563349ad9e9e01cfc9d5f26449dfca64fe68e4 Mon Sep 17 00:00:00 2001 From: Johnson Chen Date: Fri, 12 Sep 2025 13:47:42 +0800 Subject: [PATCH 1/5] feat: update supported mainnet chain to include all five chains --- src/stores/blockchain/chains.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/stores/blockchain/chains.ts b/src/stores/blockchain/chains.ts index b497938..af73319 100644 --- a/src/stores/blockchain/chains.ts +++ b/src/stores/blockchain/chains.ts @@ -98,7 +98,6 @@ export function getEntryPointAddress(version: EntryPointVersion): string { export const DEFAULT_ENTRY_POINT_VERSION: EntryPointVersion = 'v0.7' export const DEFAULT_NODE = SUPPORTED_NODE.ALCHEMY export const DEFAULT_BUNDLER = SUPPORTED_BUNDLER.PIMLICO -export const SUPPORTED_MAINNET_CHAIN_IDS = [MAINNET_CHAIN_ID.ARBITRUM, MAINNET_CHAIN_ID.BASE] export const SUPPORTED_CHAIN_IDS = getSupportedChainIds() function getSupportedChainIds(): CHAIN_ID[] { @@ -108,7 +107,7 @@ function getSupportedChainIds(): CHAIN_ID[] { // no local dev needed return Object.values(TESTNET_CHAIN_ID).filter(id => id !== TESTNET_CHAIN_ID.LOCAL) case 'production': - return SUPPORTED_MAINNET_CHAIN_IDS + return Object.values(MAINNET_CHAIN_ID) default: throw new Error(`[getSupportedChainIds] Invalid vite mode: ${import.meta.env.MODE}`) } From 9a336cda09648fe42d70f8c64fa6c54b3b5a30ca Mon Sep 17 00:00:00 2001 From: Johnson Chen Date: Fri, 12 Sep 2025 14:35:43 +0800 Subject: [PATCH 2/5] feat: enable email recovery on Base --- src/components/ExecutionModal/ExecutionUI.vue | 7 ++- src/features/email-recovery/index.ts | 1 + .../email-recovery/useEmailRecoveryStore.ts} | 0 src/features/email-recovery/utils.ts | 30 +++++++++-- src/router.ts | 18 +++---- .../AccountSettings/AccountEmailRecovery.vue | 53 +++++++++++++++---- src/views/AccountSettings/AccountSettings.vue | 10 ++-- 7 files changed, 86 insertions(+), 33 deletions(-) rename src/{stores/useEmailRecovery.ts => features/email-recovery/useEmailRecoveryStore.ts} (100%) diff --git a/src/components/ExecutionModal/ExecutionUI.vue b/src/components/ExecutionModal/ExecutionUI.vue index 76ed3dc..dfd0535 100644 --- a/src/components/ExecutionModal/ExecutionUI.vue +++ b/src/components/ExecutionModal/ExecutionUI.vue @@ -167,8 +167,11 @@ onMounted(async () => { nextTick(() => { if (!isDeployed.value && !selectedAccountInitCodeData.value) { + toast.error('Account is not deployed and initialization code not found', { + duration: ERROR_NOTIFICATION_DURATION, + }) + // Because this will close the modal, if we use throw error after emit, the toast won't show, so we use toast.error above emit('close') - throw new Error('Account not deployed and no init code provided') } }) } @@ -243,7 +246,7 @@ async function onClickEstimate() { } else { // If the account is not deployed, check if there is init code provided if (!selectedAccountInitCodeData.value) { - throw new Error('Account not deployed and no init code provided') + throw new Error('Account is not deployed and initialization code not found') } await handleEstimate({ executions: props.executions, diff --git a/src/features/email-recovery/index.ts b/src/features/email-recovery/index.ts index a083ca7..dab1623 100644 --- a/src/features/email-recovery/index.ts +++ b/src/features/email-recovery/index.ts @@ -1,2 +1,3 @@ export * from './utils' export * from './uninstallEmailRecovery' +export * from './useEmailRecoveryStore' diff --git a/src/stores/useEmailRecovery.ts b/src/features/email-recovery/useEmailRecoveryStore.ts similarity index 100% rename from src/stores/useEmailRecovery.ts rename to src/features/email-recovery/useEmailRecoveryStore.ts diff --git a/src/features/email-recovery/utils.ts b/src/features/email-recovery/utils.ts index b02113f..1cc0e2a 100644 --- a/src/features/email-recovery/utils.ts +++ b/src/features/email-recovery/utils.ts @@ -4,8 +4,22 @@ import type { JsonRpcProvider } from 'ethers' import { Contract, hexlify, Interface, isAddress } from 'ethers' import { abiEncode, ADDRESS, ERC7579_MODULE_TYPE, type ERC7579Module } from 'sendop' +import { MAINNET_CHAIN_ID, TESTNET_CHAIN_ID } from '@/stores/blockchain/chains' + export const EMAIL_RECOVERY_EXECUTOR_ADDRESS = '0x636632FA22052d2a4Fb6e3Bab84551B620b9C1F9' export const EMAIL_RELAYER_URL_BASE_SEPOLIA = 'https://auth-base-sepolia-staging.prove.email/api' +export const EMAIL_RELAYER_URL_BASE = 'https://base-relayer.zk.email/api' + +export function getEmailRelayerUrl(chainId: string): string { + switch (chainId) { + case MAINNET_CHAIN_ID.BASE: + return EMAIL_RELAYER_URL_BASE + case TESTNET_CHAIN_ID.BASE_SEPOLIA: + return EMAIL_RELAYER_URL_BASE_SEPOLIA + default: + throw new Error(`Email recovery not supported on chain ${chainId}`) + } +} /** * @param client - The JSON RPC provider @@ -17,12 +31,14 @@ export const EMAIL_RELAYER_URL_BASE_SEPOLIA = 'https://auth-base-sepolia-staging */ export async function createOwnableEmailRecovery({ client, + relayerUrl, accountAddress, email, delay = 21600n, // 6 hours (minimumDelay) expiry = 2n * 7n * 24n * 60n * 60n, // 2 weeks in seconds }: { client: JsonRpcProvider + relayerUrl: string accountAddress: string email: string delay?: bigint @@ -32,7 +48,7 @@ export async function createOwnableEmailRecovery({ const accountCodeBytes: Uint8Array = poseidon.F.random() const accountCode = hexlify(accountCodeBytes.reverse()) - const response = await fetch(`${EMAIL_RELAYER_URL_BASE_SEPOLIA}/getAccountSalt`, { + const response = await fetch(`${relayerUrl}/getAccountSalt`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -116,6 +132,7 @@ export function getEmailRecoveryExecutor({ export async function sendAcceptanceRequest( client: JsonRpcProvider, + relayerUrl: string, guardianEmail: string, accountAddress: string, accountCode: string, @@ -135,7 +152,7 @@ export async function sendAcceptanceRequest( accountCode = accountCode.slice(2) } - const response = await fetch(`${EMAIL_RELAYER_URL_BASE_SEPOLIA}/acceptanceRequest`, { + const response = await fetch(`${relayerUrl}/acceptanceRequest`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -180,11 +197,13 @@ export async function recoveryCommandTemplates(client: JsonRpcProvider) { export async function sendRecoveryRequest({ client, + relayerUrl, accountAddress, guardianEmail, newOwnerAddress, }: { client: JsonRpcProvider + relayerUrl: string accountAddress: string guardianEmail: string newOwnerAddress: string @@ -206,7 +225,7 @@ export async function sendRecoveryRequest({ .replace('{string}', keccak256(recoveryData)) // Send recovery request to relayer - const response = await fetch(`${EMAIL_RELAYER_URL_BASE_SEPOLIA}/recoveryRequest`, { + const response = await fetch(`${relayerUrl}/recoveryRequest`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -282,6 +301,7 @@ export async function getRecoveryTimeLeft(client: JsonRpcProvider, accountAddres export async function completeRecovery( client: JsonRpcProvider, + relayerUrl: string, accountAddress: string, newOwnerAddress: string, ): Promise { @@ -320,8 +340,8 @@ export async function completeRecovery( const recoveryData = abiEncode(['address', 'bytes'], [ADDRESS.OwnableValidator, addOwnerAction]) - // Response text/plain: Recovery completed - const response = await fetch(`${EMAIL_RELAYER_URL_BASE_SEPOLIA}/completeRequest`, { + // Send complete request to relayer + const response = await fetch(`${relayerUrl}/completeRequest`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/src/router.ts b/src/router.ts index cfdcea5..8dd580c 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,5 +1,5 @@ import { createRouter, createWebHistory } from 'vue-router' -import { IS_PRODUCTION, IS_SCHEDULED_SWAP_DISABLED } from './config' +import { IS_SCHEDULED_SWAP_DISABLED } from './config' const router = createRouter({ history: createWebHistory(), @@ -91,17 +91,11 @@ const router = createRouter({ name: 'account-settings-multichain', component: () => import('@/views/AccountSettings/AccountMultichain.vue'), }, - // only works on Base Sepolia - ...(IS_PRODUCTION - ? [] - : [ - { - path: 'email-recovery', - name: 'account-settings-email-recovery', - component: () => - import('@/views/AccountSettings/AccountEmailRecovery.vue'), - }, - ]), + { + path: 'email-recovery', + name: 'account-settings-email-recovery', + component: () => import('@/views/AccountSettings/AccountEmailRecovery.vue'), + }, ], }, // Send diff --git a/src/views/AccountSettings/AccountEmailRecovery.vue b/src/views/AccountSettings/AccountEmailRecovery.vue index 0eea5cd..df7b91b 100644 --- a/src/views/AccountSettings/AccountEmailRecovery.vue +++ b/src/views/AccountSettings/AccountEmailRecovery.vue @@ -6,14 +6,17 @@ import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { IS_PRODUCTION, IS_STAGING } from '@/config' import { checkAcceptanceRequest, completeRecovery, createOwnableEmailRecovery, EMAIL_RECOVERY_EXECUTOR_ADDRESS, + getEmailRelayerUrl, getRecoveryRequest, sendAcceptanceRequest, sendRecoveryRequest, + useEmailRecovery, } from '@/features/email-recovery' import { getInstallModuleData } from '@/lib/accounts/account-specific' import { getErrorMessage } from '@/lib/error' @@ -21,9 +24,8 @@ import { toRoute } from '@/lib/router' import { deserializeValidationMethod, OwnableValidatorVMethod } from '@/lib/validations' import type { ImportedAccount } from '@/stores/account/account' import { useAccount } from '@/stores/account/useAccount' -import { TESTNET_CHAIN_ID } from '@/stores/blockchain/chains' +import { displayChainName, MAINNET_CHAIN_ID, TESTNET_CHAIN_ID } from '@/stores/blockchain/chains' import { useBlockchain } from '@/stores/blockchain/useBlockchain' -import { useEmailRecovery } from '@/stores/useEmailRecovery' import { Interface } from 'ethers' import { ChevronDown, ChevronUp, Info, Loader2 } from 'lucide-vue-next' import { ADDRESS, ERC7579_MODULE_TYPE, IERC7579Account__factory } from 'sendop' @@ -92,10 +94,12 @@ const isRecoveryRequestExpired = computed(() => { return recoveryExpiry.value <= 0n }) -const isOnBaseSepolia = computed(() => selectedChainId.value === TESTNET_CHAIN_ID.BASE_SEPOLIA) +const isOnSupportedNetwork = computed( + () => selectedChainId.value === TESTNET_CHAIN_ID.BASE_SEPOLIA || selectedChainId.value === MAINNET_CHAIN_ID.BASE, +) const canUseEmailRecovery = computed(() => { - return isOnBaseSepolia.value && hasOwnableValidator.value + return isOnSupportedNetwork.value && hasOwnableValidator.value }) const recoveryTimeLeftFormatted = computed(() => { @@ -258,9 +262,19 @@ async function fetchRecoveryRequestStatus() { } } -async function onClickSwitchToBaseSepolia() { - switchChain(TESTNET_CHAIN_ID.BASE_SEPOLIA) -} +const supportedChains = computed(() => { + if (IS_PRODUCTION) { + return [{ id: MAINNET_CHAIN_ID.BASE, name: displayChainName(MAINNET_CHAIN_ID.BASE) }] + } + if (IS_STAGING) { + return [{ id: TESTNET_CHAIN_ID.BASE_SEPOLIA, name: displayChainName(TESTNET_CHAIN_ID.BASE_SEPOLIA) }] + } + // Fallback for development - show both + return [ + { id: MAINNET_CHAIN_ID.BASE, name: displayChainName(MAINNET_CHAIN_ID.BASE) }, + { id: TESTNET_CHAIN_ID.BASE_SEPOLIA, name: displayChainName(TESTNET_CHAIN_ID.BASE_SEPOLIA) }, + ] +}) const isLoadingConfigureRecovery = ref(false) async function onClickConfigureRecovery() { @@ -285,6 +299,7 @@ async function onClickConfigureRecovery() { // Create email recovery module configuration const emailRecoveryData = await createOwnableEmailRecovery({ client: client.value, + relayerUrl: getEmailRelayerUrl(selectedChainId.value), accountAddress: props.selectedAccount.address, email: guardianEmail.value, delay: BigInt(parseInt(timelockValue.value) * (timelockUnit.value === 'hours' ? 3600 : 86400)), @@ -305,6 +320,7 @@ async function onClickConfigureRecovery() { // Send acceptance request to guardian await sendAcceptanceRequest( client.value, + getEmailRelayerUrl(selectedChainId.value), guardianEmail.value, props.selectedAccount.address, emailRecoveryData.accountCode, @@ -338,6 +354,7 @@ async function onClickSendRecoveryRequest() { try { await sendRecoveryRequest({ client: client.value, + relayerUrl: getEmailRelayerUrl(selectedChainId.value), accountAddress: props.selectedAccount.address, guardianEmail: guardianEmail.value, newOwnerAddress: newOwnerAddress.value, @@ -359,7 +376,12 @@ async function onClickCompleteRecovery() { error.value = null try { - await completeRecovery(client.value, props.selectedAccount.address, newOwnerAddress.value) + await completeRecovery( + client.value, + getEmailRelayerUrl(selectedChainId.value), + props.selectedAccount.address, + newOwnerAddress.value, + ) // Update OwnableValidator addresses const owners = await OwnableValidatorAPI.getOwners(client.value, props.selectedAccount.address) @@ -486,14 +508,23 @@ const isLoading = computed(() => { -
+
- Email recovery is only available on Base Sepolia network + Email recovery is only available on Base and Base Sepolia networks
- +
+ +
diff --git a/src/views/AccountSettings/AccountSettings.vue b/src/views/AccountSettings/AccountSettings.vue index 4c53dcc..f658943 100644 --- a/src/views/AccountSettings/AccountSettings.vue +++ b/src/views/AccountSettings/AccountSettings.vue @@ -1,11 +1,10 @@