Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "smart-account-manager",
"private": true,
"version": "0.8.4",
"version": "0.8.5",
"type": "module",
"repository": "https://github.com/ethaccount/SAManager",
"author": "Johnson Chen <https://x.com/johnson86tw>",
Expand Down
7 changes: 5 additions & 2 deletions src/components/ExecutionModal/ExecutionUI.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
})
}
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/features/email-recovery/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './utils'
export * from './uninstallEmailRecovery'
export * from './useEmailRecoveryStore'
30 changes: 25 additions & 5 deletions src/features/email-recovery/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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',
Expand Down Expand Up @@ -116,6 +132,7 @@ export function getEmailRecoveryExecutor({

export async function sendAcceptanceRequest(
client: JsonRpcProvider,
relayerUrl: string,
guardianEmail: string,
accountAddress: string,
accountCode: string,
Expand All @@ -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',
Expand Down Expand Up @@ -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
Expand All @@ -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',
Expand Down Expand Up @@ -282,6 +301,7 @@ export async function getRecoveryTimeLeft(client: JsonRpcProvider, accountAddres

export async function completeRecovery(
client: JsonRpcProvider,
relayerUrl: string,
accountAddress: string,
newOwnerAddress: string,
): Promise<boolean> {
Expand Down Expand Up @@ -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',
Expand Down
18 changes: 6 additions & 12 deletions src/router.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions src/stores/blockchain/chains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand All @@ -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}`)
}
Expand Down
53 changes: 42 additions & 11 deletions src/views/AccountSettings/AccountEmailRecovery.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,26 @@ 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'
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'
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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() {
Expand All @@ -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)),
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -486,14 +508,23 @@ const isLoading = computed(() => {
</div>

<!-- Network Check -->
<div v-else-if="!isOnBaseSepolia" class="space-y-3">
<div v-else-if="!isOnSupportedNetwork" class="space-y-3">
<div class="flex items-center gap-2 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-md">
<Info class="w-4 h-4 text-yellow-500 flex-shrink-0" />
<div class="text-sm text-yellow-700 dark:text-yellow-400">
Email recovery is only available on Base Sepolia network
Email recovery is only available on Base and Base Sepolia networks
</div>
</div>
<Button variant="outline" @click="onClickSwitchToBaseSepolia"> Switch to Base Sepolia </Button>
<div class="flex flex-wrap gap-2">
<Button
v-for="chain in supportedChains"
:key="chain.id"
variant="outline"
@click="switchChain(chain.id)"
>
Switch to {{ chain.name }}
</Button>
</div>
</div>

<!-- OwnableValidator Check -->
Expand Down
10 changes: 7 additions & 3 deletions src/views/AccountSettings/AccountSettings.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
<script setup lang="ts">
import { IS_STAGING } from '@/config'
import { AccountRegistry, useAccountList } from '@/lib/accounts'
import { toRoute } from '@/lib/router'
import { useGetCode } from '@/lib/useGetCode'
import { getVMethodName, getVMethodType } from '@/lib/validations/helpers'
import { useAccount } from '@/stores/account/useAccount'
import { displayChainName } from '@/stores/blockchain/chains'
import { displayChainName, MAINNET_CHAIN_ID, TESTNET_CHAIN_ID } from '@/stores/blockchain/chains'
import { useBlockchain } from '@/stores/blockchain/useBlockchain'
import { shortenAddress } from '@vue-dapp/core'
import { ArrowLeft, Loader2, Trash } from 'lucide-vue-next'
Expand All @@ -16,6 +15,7 @@ const router = useRouter()
const { selectedAccount, isModular, isChainIdMatching, isMultichain } = useAccount()
const { getCode, isDeployed, loading } = useGetCode()
const { onClickDeleteAccount } = useAccountList()
const { selectedChainId } = useBlockchain()

const isGetCodeFinished = ref(false)

Expand Down Expand Up @@ -63,6 +63,10 @@ function onClickSwitchToCorrectChain() {
const showSwitchToCorrectChain = computed(() => {
return !isMultichain.value && !isChainIdMatching.value
})

const isOnEmailRecoverySupportedNetwork = computed(
() => selectedChainId.value === TESTNET_CHAIN_ID.BASE_SEPOLIA || selectedChainId.value === MAINNET_CHAIN_ID.BASE,
)
</script>

<template>
Expand Down Expand Up @@ -297,7 +301,7 @@ const showSwitchToCorrectChain = computed(() => {
</RouterLink>

<RouterLink
v-if="IS_STAGING"
v-if="isOnEmailRecoverySupportedNetwork"
:to="toRoute('account-settings-email-recovery', { address: selectedAccount.address })"
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
:class="
Expand Down
Loading