From 2323be7b76a40a2a7e723fe6d1567af1aacb46dc Mon Sep 17 00:00:00 2001 From: Borislav Itskov Date: Tue, 16 Jun 2026 16:22:09 +0300 Subject: [PATCH 1/5] add: first working version for scanning safes on the account adder --- .../accountPicker/accountPicker.ts | 131 ++++++++++++++++- src/controllers/safe/safe.ts | 40 ++---- src/libs/safe/safe.ts | 135 +++++++++++++++++- 3 files changed, 269 insertions(+), 37 deletions(-) diff --git a/src/controllers/accountPicker/accountPicker.ts b/src/controllers/accountPicker/accountPicker.ts index 5d72fc9395..be855441a5 100644 --- a/src/controllers/accountPicker/accountPicker.ts +++ b/src/controllers/accountPicker/accountPicker.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ -import { getCreate2Address, keccak256 } from 'ethers' +import { getAddress, getCreate2Address, keccak256 } from 'ethers' import EmittableError from '../../classes/EmittableError' import ExternalSignerError from '../../classes/ExternalSignerError' @@ -10,6 +10,7 @@ import { SMART_ACCOUNT_SIGNER_KEY_DERIVATION_OFFSET } from '../../consts/derivation' import { HARDWARE_WALLET_DEVICE_NAMES } from '../../consts/hardwareWallets' +import { SAFE_NETWORKS } from '../../consts/safe' import { Account, AccountOnchainState, @@ -48,6 +49,7 @@ import { getRelayerLinkedAccounts } from '../../libs/accountPicker/accountPicker import { getAccountState } from '../../libs/accountState/accountState' import { getDefaultKeyLabel, getExistingKeyLabel } from '../../libs/keys/keys' import { relayerCall } from '../../libs/relayerCall/relayerCall' +import { SafeImportInfo, scanSafesByOwners } from '../../libs/safe/safe' import EventEmitter from '../eventEmitter/eventEmitter' export const DEFAULT_PAGE = 1 @@ -312,9 +314,10 @@ export class AccountPickerController extends EventEmitter implements IAccountPic mergedAccounts.sort((a, b) => { const prioritizeAccountType = (item: any) => { if (!isSmartAccount(item.account)) return -1 - if (item.isLinked) return 1 + if (item.account.safeCreation) return 0 + if (item.isLinked) return 2 - return 0 + return 1 } return prioritizeAccountType(a) - prioritizeAccountType(b) || a.slot - b.slot @@ -1401,6 +1404,128 @@ export class AccountPickerController extends EventEmitter implements IAccountPic await this.findAndSetLinkedAccountsPromise } + async scanForSafeAccounts(ownerAddrs: string[]) { + if (!this.isInitialized) return this.#throwNotInitialized() + if (!this.keyIterator) return this.#throwMissingKeyIterator() + + const uniqueOwnerAddrs = Array.from(new Set(ownerAddrs.map((addr) => getAddress(addr)))) + if (!uniqueOwnerAddrs.length) return + + const safeNetworks = this.#networks.networks.filter( + (n) => + SAFE_NETWORKS.includes(Number(n.chainId)) && + !!this.#providers.providers[n.chainId.toString()] + ) + + if (!safeNetworks.length) { + this.linkedAccountsError = + 'Safe account scanning is unavailable because none of your enabled networks have Safe support.' + this.emitUpdate() + return + } + + const calledForPage = this.page + this.linkedAccountsLoading = true + this.linkedAccountsError = '' + this.emitUpdate() + + const getScannedSafeAccounts = ( + safeInfos: SafeImportInfo[] + ): { account: AccountWithNetworkMeta; isLinked: boolean }[] => + safeInfos.map((safeInfo) => { + const addr = getAddress(safeInfo.address) + const existingAccount = this.#alreadyImportedAccounts.find((acc) => acc.addr === addr) + + return { + account: { + addr, + associatedKeys: safeInfo.owners.map((owner) => getAddress(owner)), + initialPrivileges: safeInfo.owners.map( + (owner) => [getAddress(owner), '0x01'] as [string, string] + ), + creation: null, + safeCreation: { + factoryAddr: safeInfo.factoryAddr, + singleton: safeInfo.singleton, + saltNonce: safeInfo.saltNonce, + setupData: safeInfo.setupData, + version: safeInfo.version + }, + preferences: { + label: existingAccount?.preferences.label || 'Safe', + pfp: existingAccount?.preferences.pfp || addr + } + }, + isLinked: true + } + }) + + const addScannedSafeAccounts = ( + scannedSafeAccounts: { account: AccountWithNetworkMeta; isLinked: boolean }[] + ) => { + const linkedAccountsByAddress = new Map( + this.#linkedAccounts.map((linkedAccount) => [linkedAccount.account.addr, linkedAccount]) + ) + + scannedSafeAccounts.forEach((linkedAccount) => { + const existingLinkedAccount = linkedAccountsByAddress.get(linkedAccount.account.addr) + linkedAccountsByAddress.set(linkedAccount.account.addr, { + ...linkedAccount, + account: { + ...linkedAccount.account, + usedOnNetworks: + existingLinkedAccount?.account.usedOnNetworks ?? linkedAccount.account.usedOnNetworks + } + }) + }) + + this.#linkedAccounts = Array.from(linkedAccountsByAddress.values()) + } + + const safeScanErrorMessages = new Set() + + for (let i = 0; i < safeNetworks.length; i++) { + const network = safeNetworks[i]! + const { safeInfos, errorMessage } = await scanSafesByOwners({ + ownerAddrs: uniqueOwnerAddrs, + chainIds: [network.chainId] + }) + + if (calledForPage !== this.page) return + + if (errorMessage) safeScanErrorMessages.add(errorMessage) + + const scannedSafeAccounts = getScannedSafeAccounts(safeInfos) + if (!scannedSafeAccounts.length) continue + + addScannedSafeAccounts(scannedSafeAccounts) + this.#verifyLinkedAccounts() + this.linkedAccountsError = Array.from(safeScanErrorMessages).join(' ') + this.emitUpdate() + + const linkedAccountsWithNetworks = await this.#getAccountsUsedOnNetworks({ + accounts: this.#linkedAccounts as any, + page: calledForPage + }) + + if (calledForPage !== this.page) return + + this.#linkedAccounts = Array.from( + new Map( + [...this.#linkedAccounts, ...(linkedAccountsWithNetworks as any)].map((linkedAccount) => [ + linkedAccount.account.addr, + linkedAccount + ]) + ).values() + ) + this.emitUpdate() + } + + this.linkedAccountsError = Array.from(safeScanErrorMessages).join(' ') + this.linkedAccountsLoading = false + this.emitUpdate() + } + /** * The corresponding derived account for the linked accounts should always be found, * except when something is wrong with the data we have stored on the Relayer. diff --git a/src/controllers/safe/safe.ts b/src/controllers/safe/safe.ts index 4e2daf4ae1..66d12a84a9 100644 --- a/src/controllers/safe/safe.ts +++ b/src/controllers/safe/safe.ts @@ -1,14 +1,8 @@ -import { toBeHex } from 'ethers' - -import SafeApiKit, { - SafeCreationInfoResponse, - SafeInfoResponse, - SafeMessage -} from '@safe-global/api-kit' +import { SafeMessage } from '@safe-global/api-kit' import { SafeMultisigConfirmationResponse } from '@safe-global/types-kit' import { FETCH_SAFE_TXNS } from '../../consts/intervals' -import { SAFE_NETWORKS, safeNullOwner } from '../../consts/safe' +import { SAFE_NETWORKS } from '../../consts/safe' import { IAccountsController, SafeAccountCreation } from '../../interfaces/account' import { IEventEmitterRegistryController, Statuses } from '../../interfaces/eventEmitter' import { Hex } from '../../interfaces/hex' @@ -20,6 +14,7 @@ import { ExtendedSafeMessage, fetchAllPending, fetchExecutedTransactions, + getSafeImportInfo, getMessage, SafeResults } from '../../libs/safe/safe' @@ -131,18 +126,12 @@ export class SafeController extends EventEmitter implements ISafeController { return } - const apiKit = new SafeApiKit({ + const safeImportInfo = await getSafeImportInfo({ + safeAddr, chainId: deployedOn.chainId, - apiKey: process.env.SAFE_API_KEY + deployedOn: codes.filter((c) => c.code !== '0x').map((c) => c.chainId) }) - const [safeInfo, safeCreationInfo]: [ - SafeInfoResponse | Error, - SafeCreationInfoResponse | Error - ] = await Promise.all([ - apiKit.getSafeInfo(safeAddr).catch((e) => e), - apiKit.getSafeCreationInfo(safeAddr).catch((e) => e) - ]) - if (safeInfo instanceof Error || safeCreationInfo instanceof Error) { + if (!safeImportInfo) { this.importError = { address: safeAddr, message: 'Failed to retrieve information about the Safe. Please try again' @@ -150,20 +139,7 @@ export class SafeController extends EventEmitter implements ISafeController { return } - const setupData = safeCreationInfo.setupData as Hex - this.safeInfo = { - version: safeInfo.version, - address: safeInfo.address as Hex, - owners: safeInfo.owners as Hex[], - deployedOn: codes.filter((c) => c.code !== '0x').map((c) => c.chainId), - factoryAddr: safeCreationInfo.factoryAddress as Hex, - singleton: safeCreationInfo.singleton as Hex, - saltNonce: safeCreationInfo.saltNonce - ? (toBeHex(BigInt(safeCreationInfo.saltNonce), 32) as Hex) - : (toBeHex(0, 32) as Hex), - setupData, - requiresModules: safeInfo.owners.length === 1 && safeInfo.owners[0] === safeNullOwner - } + this.safeInfo = safeImportInfo } async findSafe(safeAddr: string) { diff --git a/src/libs/safe/safe.ts b/src/libs/safe/safe.ts index f4387e0572..e48b988e32 100644 --- a/src/libs/safe/safe.ts +++ b/src/libs/safe/safe.ts @@ -20,6 +20,7 @@ import { SignTypedDataVersion, TypedDataUtils } from '@metamask/eth-sig-util' import SafeApiKit, { ProposeTransactionProps, SafeCreationInfoResponse, + SafeInfoResponse, SafeMessage, SafeMessageListResponse, SafeMultisigTransactionListResponse @@ -31,8 +32,8 @@ import { } from '@safe-global/types-kit' import SafeAbi from '../../../contracts/compiled/Safe.json' -import { execTransactionAbi, multiSendAddr } from '../../consts/safe' -import { AccountOnchainState } from '../../interfaces/account' +import { execTransactionAbi, multiSendAddr, safeNullOwner } from '../../consts/safe' +import { AccountOnchainState, SafeAccountCreation } from '../../interfaces/account' import { Hex } from '../../interfaces/hex' import { RPCProvider } from '../../interfaces/provider' import { SafeTx } from '../../interfaces/safe' @@ -64,6 +65,136 @@ export interface SafeResults { } } +export type SafeImportInfo = SafeAccountCreation & { + address: Hex + deployedOn: bigint[] + owners: Hex[] + requiresModules: boolean +} + +export async function getSafeImportInfo({ + safeAddr, + chainId, + deployedOn = [chainId] +}: { + safeAddr: string + chainId: bigint + deployedOn?: bigint[] +}): Promise { + const apiKit = new SafeApiKit({ + chainId, + apiKey: process.env.SAFE_API_KEY + }) + const [safeInfo, safeCreationInfo]: [SafeInfoResponse | Error, SafeCreationInfoResponse | Error] = + await Promise.all([ + apiKit.getSafeInfo(safeAddr).catch((e) => e), + apiKit.getSafeCreationInfo(safeAddr).catch((e) => e) + ]) + + if (safeInfo instanceof Error || safeCreationInfo instanceof Error) return null + + return { + version: safeInfo.version, + address: safeInfo.address as Hex, + owners: safeInfo.owners as Hex[], + deployedOn, + factoryAddr: safeCreationInfo.factoryAddress as Hex, + singleton: safeCreationInfo.singleton as Hex, + saltNonce: safeCreationInfo.saltNonce + ? (toBeHex(BigInt(safeCreationInfo.saltNonce), 32) as Hex) + : (toBeHex(0, 32) as Hex), + setupData: safeCreationInfo.setupData as Hex, + requiresModules: safeInfo.owners.length === 1 && safeInfo.owners[0] === safeNullOwner + } +} + +export async function scanSafesByOwners({ + ownerAddrs, + chainIds +}: { + ownerAddrs: string[] + chainIds: bigint[] +}): Promise<{ + safeInfos: SafeImportInfo[] + errorMessage?: string +}> { + const safeAddressesByChainId = new Map>() + const scanErrors: string[] = [] + const ownerScanRequests = ownerAddrs.flatMap((ownerAddr) => + chainIds.map((chainId) => ({ ownerAddr, chainId })) + ) + let ownerScanPromises = [] + + for (let i = 0; i < ownerScanRequests.length; i++) { + const { ownerAddr, chainId } = ownerScanRequests[i]! + const apiKit = new SafeApiKit({ + chainId, + apiKey: process.env.SAFE_API_KEY + }) + + ownerScanPromises.push( + apiKit.getSafesByOwner(ownerAddr).then((response) => ({ + response, + chainId + })) + ) + + if ((i + 1) % 3 === 0 || i + 1 === ownerScanRequests.length) { + const responses = await Promise.all( + ownerScanPromises.map((promise) => promise.catch((e) => e)) + ) + responses.forEach((result) => { + if (result instanceof Error) { + scanErrors.push(result.message) + return + } + + result.response.safes.forEach((safeAddr: string) => { + const checksummedSafeAddr = getAddress(safeAddr) + const safeChainIds = safeAddressesByChainId.get(checksummedSafeAddr) || new Set() + safeChainIds.add(result.chainId) + safeAddressesByChainId.set(checksummedSafeAddr, safeChainIds) + }) + }) + await wait(1100) + ownerScanPromises = [] + } + } + + const safeInfos: SafeImportInfo[] = [] + const safeInfoRequests = Array.from(safeAddressesByChainId.entries()) + let safeInfoPromises = [] + + for (let i = 0; i < safeInfoRequests.length; i++) { + const [safeAddr, deployedOn] = safeInfoRequests[i]! + const firstChainId = Array.from(deployedOn)[0]! + + safeInfoPromises.push( + getSafeImportInfo({ + safeAddr, + chainId: firstChainId, + deployedOn: Array.from(deployedOn) + }) + ) + + if ((i + 1) % 2 === 0 || i + 1 === safeInfoRequests.length) { + const responses = await Promise.all(safeInfoPromises) + safeInfos.push(...responses.filter((safeInfo): safeInfo is SafeImportInfo => !!safeInfo)) + await wait(1100) + safeInfoPromises = [] + } + } + + return { + safeInfos, + errorMessage: scanErrors.length + ? `The attempt to discover Safe accounts failed for some networks. Error details: <${[ + ...new Set(scanErrors) + ].join('; ')}>` + : undefined + } +} + export function encodeCalls(op: AccountOp): { to: Hex value: bigint From 595d120e3f2fca1c64008309a3f904aa2e859085 Mon Sep 17 00:00:00 2001 From: Borislav Itskov Date: Tue, 16 Jun 2026 16:58:27 +0300 Subject: [PATCH 2/5] move the safe accounts to their own section in the account picker --- src/controllers/accountPicker/accountPicker.ts | 14 ++++++++++++-- src/libs/account/account.ts | 1 + 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/controllers/accountPicker/accountPicker.ts b/src/controllers/accountPicker/accountPicker.ts index be855441a5..96a3f39b78 100644 --- a/src/controllers/accountPicker/accountPicker.ts +++ b/src/controllers/accountPicker/accountPicker.ts @@ -133,6 +133,10 @@ export class AccountPickerController extends EventEmitter implements IAccountPic linkedAccountsLoading: boolean = false + safeAccountsLoading: boolean = false + + safeAccountsScanCompleted: boolean = false + linkedAccountsError: string = '' networksWithAccountStateError: bigint[] = [] @@ -491,6 +495,8 @@ export class AccountPickerController extends EventEmitter implements IAccountPic this.pageError = null this.linkedAccountsLoading = false + this.safeAccountsLoading = false + this.safeAccountsScanCompleted = false this.linkedAccountsError = '' this.addAccountsStatus = 'INITIAL' this.#derivedAccounts = [] @@ -717,6 +723,8 @@ export class AccountPickerController extends EventEmitter implements IAccountPic this.accountsLoading = true this.networksWithAccountStateError = [] this.linkedAccountsLoading = false + this.safeAccountsLoading = false + this.safeAccountsScanCompleted = false this.emitUpdate() if (page <= 0) { @@ -1425,7 +1433,8 @@ export class AccountPickerController extends EventEmitter implements IAccountPic } const calledForPage = this.page - this.linkedAccountsLoading = true + this.safeAccountsLoading = true + this.safeAccountsScanCompleted = false this.linkedAccountsError = '' this.emitUpdate() @@ -1522,7 +1531,8 @@ export class AccountPickerController extends EventEmitter implements IAccountPic } this.linkedAccountsError = Array.from(safeScanErrorMessages).join(' ') - this.linkedAccountsLoading = false + this.safeAccountsLoading = false + this.safeAccountsScanCompleted = true this.emitUpdate() } diff --git a/src/libs/account/account.ts b/src/libs/account/account.ts index cf9848fcfd..46c8237fc0 100644 --- a/src/libs/account/account.ts +++ b/src/libs/account/account.ts @@ -223,6 +223,7 @@ export const getAccountImportStatus = ({ }): ImportStatus => { const isAlreadyImported = alreadyImportedAccounts.some(({ addr }) => addr === account.addr) if (!isAlreadyImported) return ImportStatus.NotImported + if (account.safeCreation) return ImportStatus.ImportedWithTheSameKeys // Check if the account has been imported with at least one of the keys // that the account was originally associated with, when it was imported. From 77b09c4c8bfceb2b7eac3b0510660beb291e0674 Mon Sep 17 00:00:00 2001 From: Borislav Itskov Date: Tue, 16 Jun 2026 17:34:20 +0300 Subject: [PATCH 3/5] add: make linked accounts on demand as well --- src/controllers/accountPicker/accountPicker.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/controllers/accountPicker/accountPicker.ts b/src/controllers/accountPicker/accountPicker.ts index 96a3f39b78..0375649517 100644 --- a/src/controllers/accountPicker/accountPicker.ts +++ b/src/controllers/accountPicker/accountPicker.ts @@ -133,6 +133,8 @@ export class AccountPickerController extends EventEmitter implements IAccountPic linkedAccountsLoading: boolean = false + linkedAccountsScanCompleted: boolean = false + safeAccountsLoading: boolean = false safeAccountsScanCompleted: boolean = false @@ -495,6 +497,7 @@ export class AccountPickerController extends EventEmitter implements IAccountPic this.pageError = null this.linkedAccountsLoading = false + this.linkedAccountsScanCompleted = false this.safeAccountsLoading = false this.safeAccountsScanCompleted = false this.linkedAccountsError = '' @@ -723,6 +726,7 @@ export class AccountPickerController extends EventEmitter implements IAccountPic this.accountsLoading = true this.networksWithAccountStateError = [] this.linkedAccountsLoading = false + this.linkedAccountsScanCompleted = false this.safeAccountsLoading = false this.safeAccountsScanCompleted = false this.emitUpdate() @@ -787,7 +791,7 @@ export class AccountPickerController extends EventEmitter implements IAccountPic if (this.page !== page) return - await this.findAndSetLinkedAccounts() + if (this.shouldSearchForLinkedAccounts) await this.findAndSetLinkedAccounts() } #updateStateWithTheLatestFromAccounts() { @@ -1274,9 +1278,11 @@ export class AccountPickerController extends EventEmitter implements IAccountPic } async #findAndSetLinkedAccounts({ accounts }: { accounts: Account[] }) { - if (!this.shouldSearchForLinkedAccounts) return - - if (accounts.length === 0) return + if (accounts.length === 0) { + this.linkedAccountsScanCompleted = true + this.emitUpdate() + return + } // Cache the page and the abort controller at the start to use throughout // the operation, even if reset() clears them mid-process @@ -1284,6 +1290,7 @@ export class AccountPickerController extends EventEmitter implements IAccountPic const calledForAbortController = this.#findAndSetLinkedAccountsAbortController this.linkedAccountsLoading = true + this.linkedAccountsScanCompleted = false this.linkedAccountsError = '' this.emitUpdate() @@ -1382,6 +1389,7 @@ export class AccountPickerController extends EventEmitter implements IAccountPic this.#linkedAccounts = linkedAccountsWithNetworks this.linkedAccountsLoading = false + this.linkedAccountsScanCompleted = true this.emitUpdate() } From 02ba7814fb1e81be13d4dff45c98adf8f7e5f1df Mon Sep 17 00:00:00 2001 From: Borislav Itskov Date: Tue, 16 Jun 2026 18:17:05 +0300 Subject: [PATCH 4/5] derive only the initial 5 accounts for ledger without the smart account ones unless strictly requested from the user --- .../accountPicker/accountPicker.test.ts | 57 ++++++- .../accountPicker/accountPicker.ts | 143 +++++++++++------- 2 files changed, 142 insertions(+), 58 deletions(-) diff --git a/src/controllers/accountPicker/accountPicker.test.ts b/src/controllers/accountPicker/accountPicker.test.ts index d5088f2859..49c35f146c 100644 --- a/src/controllers/accountPicker/accountPicker.test.ts +++ b/src/controllers/accountPicker/accountPicker.test.ts @@ -1,6 +1,6 @@ import { Wallet } from 'ethers' -import { describe, expect, test } from '@jest/globals' +import { describe, expect, jest, test } from '@jest/globals' import { suppressConsoleBeforeEach } from '../../../test/helpers/console' import { makeMainController } from '../../../test/helpers/mainController' @@ -125,10 +125,12 @@ describe('AccountPicker', () => { }) }) - test('should retrieve 5 basic and one smart account on each page', async () => { + test('should retrieve smart account keys only when scanning for smart accounts', async () => { const { controller } = await prepareTest() const PAGE_SIZE = 5 const keyIterator = new KeyIterator(process.env.SEED) + const retrieveSpy = jest.spyOn(keyIterator, 'retrieve') + controller.setInitParams({ keyIterator, pageSize: PAGE_SIZE, @@ -139,14 +141,52 @@ describe('AccountPicker', () => { }) await controller.init() await controller.setPage({ page: 1 }) - expect(controller.accountsOnPage).toHaveLength(6) - expect(controller.accountsOnPage.filter((a) => isSmartAccount(a.account))).toHaveLength(1) + expect(retrieveSpy).toHaveBeenLastCalledWith( + [{ from: 0, to: PAGE_SIZE - 1 }], + BIP44_STANDARD_DERIVATION_TEMPLATE + ) + expect(controller.accountsOnPage).toHaveLength(5) + expect(controller.accountsOnPage.filter((a) => isSmartAccount(a.account))).toHaveLength(0) + expect(controller.accountsOnPage.filter((a) => !isSmartAccount(a.account))).toHaveLength(5) + + await controller.findAndSetLinkedAccounts() + expect(retrieveSpy).toHaveBeenCalledWith( + [ + { + from: SMART_ACCOUNT_SIGNER_KEY_DERIVATION_OFFSET, + to: SMART_ACCOUNT_SIGNER_KEY_DERIVATION_OFFSET + PAGE_SIZE - 1 + } + ], + BIP44_STANDARD_DERIVATION_TEMPLATE + ) expect(controller.accountsOnPage.filter((a) => !isSmartAccount(a.account))).toHaveLength(5) + expect( + controller.accountsOnPage.some((a) => isSmartAccount(a.account) && !a.isLinked) + ).toBeTruthy() await controller.setPage({ page: 2 }) - expect(controller.accountsOnPage).toHaveLength(6) - expect(controller.accountsOnPage.filter((a) => isSmartAccount(a.account))).toHaveLength(1) + expect(retrieveSpy).toHaveBeenLastCalledWith( + [{ from: PAGE_SIZE, to: PAGE_SIZE * 2 - 1 }], + BIP44_STANDARD_DERIVATION_TEMPLATE + ) + expect(controller.accountsOnPage).toHaveLength(5) + expect(controller.accountsOnPage.filter((a) => isSmartAccount(a.account))).toHaveLength(0) + expect(controller.accountsOnPage.filter((a) => !isSmartAccount(a.account))).toHaveLength(5) + + await controller.findAndSetLinkedAccounts() + expect(retrieveSpy).toHaveBeenCalledWith( + [ + { + from: SMART_ACCOUNT_SIGNER_KEY_DERIVATION_OFFSET + PAGE_SIZE, + to: SMART_ACCOUNT_SIGNER_KEY_DERIVATION_OFFSET + PAGE_SIZE * 2 - 1 + } + ], + BIP44_STANDARD_DERIVATION_TEMPLATE + ) expect(controller.accountsOnPage.filter((a) => !isSmartAccount(a.account))).toHaveLength(5) + expect( + controller.accountsOnPage.some((a) => isSmartAccount(a.account) && !a.isLinked) + ).toBeTruthy() }) test('should find linked accounts', async () => { @@ -262,8 +302,11 @@ describe('AccountPicker', () => { }) await controller.init() await controller.setPage({ page: 1 }) + await controller.findAndSetLinkedAccounts() - const smartAccount = controller.accountsOnPage.find((x) => isSmartAccount(x.account)) + const smartAccount = controller.accountsOnPage.find( + (x) => isSmartAccount(x.account) && !x.isLinked + ) if (smartAccount) controller.selectAccount(smartAccount.account) expect(controller.selectedAccounts[0]!.accountKeys) diff --git a/src/controllers/accountPicker/accountPicker.ts b/src/controllers/accountPicker/accountPicker.ts index 0375649517..bbd623164c 100644 --- a/src/controllers/accountPicker/accountPicker.ts +++ b/src/controllers/accountPicker/accountPicker.ts @@ -1084,42 +1084,55 @@ export class AccountPickerController extends EventEmitter implements IAccountPic const startIdx = (this.page - 1) * this.pageSize const endIdx = (this.page - 1) * this.pageSize + (this.pageSize - 1) - const indicesToRetrieve = [ - { from: startIdx, to: endIdx } // Indices for the basic (EOA) accounts - ] - // Since v4.31.0, do not retrieve smart accounts for the private key - // type. That's because we can't use the common derivation offset - // (SMART_ACCOUNT_SIGNER_KEY_DERIVATION_OFFSET), and deriving smart - // accounts out of the private key (with another approach - salt and - // extra entropy) was creating confusion. - // - // + no smart accounts for QR wallets. Reasons: - // - some hws sign only if the signer is imported - // - we are generally moving in another direction - const shouldRetrieveSmartAccountIndices = - this.keyIterator.subType !== 'private-key' && this.type !== 'qr' - if (shouldRetrieveSmartAccountIndices) { - // Indices for the smart accounts. - indicesToRetrieve.push({ - from: startIdx + SMART_ACCOUNT_SIGNER_KEY_DERIVATION_OFFSET, - to: endIdx + SMART_ACCOUNT_SIGNER_KEY_DERIVATION_OFFSET - }) - } - // Combine the requests for all accounts in one call to the keyIterator. - // That's optimization primarily focused on hardware wallets, to reduce the - // number of calls to the hardware device. This is important, especially - // for Trezor, because it fires a confirmation popup for each call. - const combinedBasicAndSmartAccKeys = await this.keyIterator.retrieve( - indicesToRetrieve, + const basicAccKeys = await this.keyIterator.retrieve( + [{ from: startIdx, to: endIdx }], this.hdPathTemplate ) - const basicAccKeys = combinedBasicAndSmartAccKeys.slice(0, this.pageSize) - const smartAccKeys = combinedBasicAndSmartAccKeys.slice( - this.pageSize, - combinedBasicAndSmartAccKeys.length + for (const [index, basicAccKey] of basicAccKeys.entries()) { + const slot = startIdx + (index + 1) + // The EOA (basic) account on this slot + const account = getBasicAccount(basicAccKey, this.#alreadyImportedAccounts) + const result = { account, isLinked: false, slot, index: slot - 1 } + accounts.push(result) + } + + return accounts + } + + async #deriveSmartAccountsForCurrentPage({ + calledForPage, + calledForAbortController + }: { + calledForPage: number + calledForAbortController: AbortController | undefined + }) { + if (!this.keyIterator) return this.#throwMissingKeyIterator() + if (!this.hdPathTemplate) return this.#throwMissingHdPath() + if (this.keyIterator.subType === 'private-key' || this.type === 'qr') return + + const startIdx = (this.page - 1) * this.pageSize + const endIdx = (this.page - 1) * this.pageSize + (this.pageSize - 1) + const startIdxWithOffset = startIdx + SMART_ACCOUNT_SIGNER_KEY_DERIVATION_OFFSET + const endIdxWithOffset = endIdx + SMART_ACCOUNT_SIGNER_KEY_DERIVATION_OFFSET + + const derivedSmartAccountKeyIndices = new Set( + this.#derivedAccounts.filter((acc) => !isSmartAccount(acc.account)).map((acc) => acc.index) ) + const areSmartAccountKeysAlreadyDerived = Array.from( + { length: this.pageSize }, + (_, index) => startIdxWithOffset + index + ).every((index) => derivedSmartAccountKeyIndices.has(index)) + if (areSmartAccountKeysAlreadyDerived) return + + const smartAccKeys = await this.keyIterator.retrieve( + [{ from: startIdxWithOffset, to: endIdxWithOffset }], + this.hdPathTemplate + ) + + if (this.#isFindAndSetLinkedAccountsCancelled(calledForPage, calledForAbortController)) return + const accounts: DerivedAccountWithoutNetworkMeta[] = [] const smartAccountsPromises: Promise[] = [] // Replace the parallel getKeys with foreach to prevent issues with Ledger, // which can only handle one request at a time. @@ -1158,6 +1171,8 @@ export class AccountPickerController extends EventEmitter implements IAccountPic await new Promise((resolve) => setTimeout(resolve, 0)) } + if (this.#isFindAndSetLinkedAccountsCancelled(calledForPage, calledForAbortController)) return + const unfilteredSmartAccountsList = await Promise.all(smartAccountsPromises) const smartAccounts = unfilteredSmartAccountsList.filter( (x) => x !== null @@ -1165,15 +1180,24 @@ export class AccountPickerController extends EventEmitter implements IAccountPic accounts.push(...smartAccounts) - for (const [index, basicAccKey] of basicAccKeys.entries()) { - const slot = startIdx + (index + 1) - // The EOA (basic) account on this slot - const account = getBasicAccount(basicAccKey, this.#alreadyImportedAccounts) - const result = { account, isLinked: false, slot, index: slot - 1 } - accounts.push(result) - } + const accountsWithUsedOn = await this.#getAccountsUsedOnNetworks({ + accounts, + page: calledForPage + }) - return accounts + if (this.#isFindAndSetLinkedAccountsCancelled(calledForPage, calledForAbortController)) return + + const derivedAccountsByKey = new Map( + this.#derivedAccounts.map((acc) => [`${acc.index}-${acc.account.addr}`, acc]) + ) + accountsWithUsedOn.forEach((acc) => { + derivedAccountsByKey.set(`${acc.index}-${acc.account.addr}`, acc) + }) + + this.#derivedAccounts = Array.from(derivedAccountsByKey.values()).sort( + (a, b) => a.index - b.index + ) + this.emitUpdate() } async #getAccountsUsedOnNetworks({ @@ -1279,6 +1303,7 @@ export class AccountPickerController extends EventEmitter implements IAccountPic async #findAndSetLinkedAccounts({ accounts }: { accounts: Account[] }) { if (accounts.length === 0) { + this.linkedAccountsLoading = false this.linkedAccountsScanCompleted = true this.emitUpdate() return @@ -1401,19 +1426,35 @@ export class AccountPickerController extends EventEmitter implements IAccountPic // Create a new AbortController for this operation this.#findAndSetLinkedAccountsAbortController = new AbortController() + const calledForPage = this.page + const calledForAbortController = this.#findAndSetLinkedAccountsAbortController - this.findAndSetLinkedAccountsPromise = this.#findAndSetLinkedAccounts({ - accounts: this.#derivedAccounts - .filter( - (acc) => - // Since v4.60.0, linked accounts are searched for 1) EOAs - // and 2) EOAs derived for Smart Account keys ONLY - // (workaround so that the Relayer returns information if the Smart - // Account with this key is used (with identity) or not). - !isSmartAccount(acc.account) || isDerivedForSmartAccountKeyOnly(acc.index) - ) - .map((acc) => acc.account) - }).finally(() => { + this.linkedAccountsLoading = true + this.linkedAccountsScanCompleted = false + this.linkedAccountsError = '' + this.emitUpdate() + + this.findAndSetLinkedAccountsPromise = (async () => { + await this.#deriveSmartAccountsForCurrentPage({ + calledForPage, + calledForAbortController + }) + + if (this.#isFindAndSetLinkedAccountsCancelled(calledForPage, calledForAbortController)) return + + await this.#findAndSetLinkedAccounts({ + accounts: this.#derivedAccounts + .filter( + (acc) => + // Since v4.60.0, linked accounts are searched for 1) EOAs + // and 2) EOAs derived for Smart Account keys ONLY + // (workaround so that the Relayer returns information if the Smart + // Account with this key is used (with identity) or not). + !isSmartAccount(acc.account) || isDerivedForSmartAccountKeyOnly(acc.index) + ) + .map((acc) => acc.account) + }) + })().finally(() => { this.findAndSetLinkedAccountsPromise = undefined this.#findAndSetLinkedAccountsAbortController = undefined }) From 956cba678754e8049639bc552758f7cce2ab1929 Mon Sep 17 00:00:00 2001 From: Borislav Itskov Date: Wed, 17 Jun 2026 09:04:14 +0300 Subject: [PATCH 5/5] if the account is a safe, do not do linked accounts verification --- .../accountPicker/accountPicker.ts | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/controllers/accountPicker/accountPicker.ts b/src/controllers/accountPicker/accountPicker.ts index bbd623164c..ebf6bcc7ba 100644 --- a/src/controllers/accountPicker/accountPicker.ts +++ b/src/controllers/accountPicker/accountPicker.ts @@ -1591,23 +1591,25 @@ export class AccountPickerController extends EventEmitter implements IAccountPic * Also, could be an attack vector. So indicate to the user that something is wrong. */ #verifyLinkedAccounts() { - this.#linkedAccounts.forEach((linkedAcc) => { - const correspondingDerivedAccount = this.#derivedAccounts.find((derivedAccount) => - linkedAcc.account.associatedKeys.includes(derivedAccount.account.addr) - ) + this.#linkedAccounts + .filter((linkedAcc) => !linkedAcc.account.safeCreation) + .forEach((linkedAcc) => { + const correspondingDerivedAccount = this.#derivedAccounts.find((derivedAccount) => + linkedAcc.account.associatedKeys.includes(derivedAccount.account.addr) + ) - // The `correspondingDerivedAccount` should always be found, - // except something is wrong with the data we have stored on the Relayer - if (!correspondingDerivedAccount) { - this.emitError({ - level: 'major', - message: `Something went wrong with finding the corresponding account in the associated keys of the linked account with address ${linkedAcc.account.addr}. Please start the process again. If the problem persists, contact support.`, - error: new Error( - `Something went wrong with finding the corresponding account in the associated keys of the linked account with address ${linkedAcc.account.addr}.` - ) - }) - } - }) + // The `correspondingDerivedAccount` should always be found, + // except something is wrong with the data we have stored on the Relayer + if (!correspondingDerivedAccount) { + this.emitError({ + level: 'major', + message: `Something went wrong with finding the corresponding account in the associated keys of the linked account with address ${linkedAcc.account.addr}. Please start the process again. If the problem persists, contact support.`, + error: new Error( + `Something went wrong with finding the corresponding account in the associated keys of the linked account with address ${linkedAcc.account.addr}.` + ) + }) + } + }) } #throwNotInitialized() {