diff --git a/.changeset/wallet-init-fewer-requests.md b/.changeset/wallet-init-fewer-requests.md new file mode 100644 index 000000000..a8788d01b --- /dev/null +++ b/.changeset/wallet-init-fewer-requests.md @@ -0,0 +1,7 @@ +--- +"@crossmint/wallets-sdk": patch +--- + +`getWallet` resolves faster when the device signer's key is on this device: signer initialization reads the signer's status from the wallet response instead of fetching the wallet and each signer again, and the device key storage (an iframe on web) starts loading alongside the wallet request. `DeviceSignerKeyStorage` gains an optional `preload()` hook (no-op by default). + +`wallet.signers()` makes one request instead of one per signer: it reads each signer's status from the wallet response. diff --git a/packages/wallets/src/utils/device-signers/DeviceSignerKeyStorage.ts b/packages/wallets/src/utils/device-signers/DeviceSignerKeyStorage.ts index 121d79800..e13e97a03 100644 --- a/packages/wallets/src/utils/device-signers/DeviceSignerKeyStorage.ts +++ b/packages/wallets/src/utils/device-signers/DeviceSignerKeyStorage.ts @@ -65,6 +65,12 @@ export abstract class DeviceSignerKeyStorage { */ abstract deleteKey(address: string): Promise; + /** + * Start any slow setup (e.g. loading an iframe) so later calls don't wait for it. Called when a wallet + * starts loading, before its first request. Never throws; the default does nothing. + */ + preload(): void {} + /** * Get a human-readable name for this device (e.g. "MacBook Pro", "iPhone 15"). * Used to label device signers in the API for identification purposes. diff --git a/packages/wallets/src/utils/device-signers/IframeDeviceSignerKeyStorage.ts b/packages/wallets/src/utils/device-signers/IframeDeviceSignerKeyStorage.ts index 81f036965..5759ed37a 100644 --- a/packages/wallets/src/utils/device-signers/IframeDeviceSignerKeyStorage.ts +++ b/packages/wallets/src/utils/device-signers/IframeDeviceSignerKeyStorage.ts @@ -126,6 +126,15 @@ export class IframeDeviceSignerKeyStorage extends DeviceSignerKeyStorage { await this.rpc("deleteKey", { address }); } + /** Start loading the iframe, so the first key lookup doesn't wait for it. */ + preload(): void { + if (typeof document === "undefined") { + return; + } + // a failed load is retried, and surfaced, by the next RPC + this.getIframe().catch(() => {}); + } + destroy(): void { if (this.biometricListener != null) { window.removeEventListener("message", this.biometricListener); diff --git a/packages/wallets/src/wallets/services/device-recovery-service.ts b/packages/wallets/src/wallets/services/device-recovery-service.ts index 0b4a49676..2550397c0 100644 --- a/packages/wallets/src/wallets/services/device-recovery-service.ts +++ b/packages/wallets/src/wallets/services/device-recovery-service.ts @@ -17,6 +17,8 @@ import { DeviceSignerNotSupportedError, SignerLimitExceededError } from "../../u import { createDeviceSigner } from "@/utils/device-signers"; import type { DeviceSignerKeyStorage } from "@/utils/device-signers/DeviceSignerKeyStorage"; import { walletsLogger } from "../../logger"; +import type { Signer as APISigner } from "../../api"; +import { mapApiSignerToSigner } from "../../utils/signer-mapping"; import type { PendingSignerOperation, Signer as WalletSigner, WalletOptions } from "../types"; import type { SignerManager } from "./signer-manager"; @@ -81,7 +83,11 @@ export class DeviceRecoveryService { } } - async initDeviceSigner(): Promise { + /** + * @param apiSigners - Delegated signers as the API just returned them with the wallet. When given, the + * device signer is matched and its status read from them, with no request to the API. + */ + async initDeviceSigner(apiSigners?: APISigner[]): Promise { const deviceSignerKeyStorage = this.#options?.deviceSignerKeyStorage; if (deviceSignerKeyStorage == null) { return; @@ -92,7 +98,7 @@ export class DeviceRecoveryService { const deviceConfig: DeviceSignerConfig = { type: "device" }; try { - await this.resolveAvailability(deviceConfig); + await this.resolveAvailability(deviceConfig, apiSigners); } catch (error) { walletsLogger.error("wallet.initDeviceSigner.error", { error }); this.#status = "needs-recovery"; @@ -107,7 +113,7 @@ export class DeviceRecoveryService { deviceConfig as SignerConfigForChain, this.#signerManager.descriptorContext() ); - const deviceSigner = await this.#signerManager.assemble(internalConfig); + const deviceSigner = await this.#signerManager.assemble(internalConfig, { apiSigners }); this.#signerManager.setActiveSigner(deviceSigner); if (!this.#signerManager.isApprovedSignerStatus(deviceSigner.status)) { @@ -370,7 +376,7 @@ export class DeviceRecoveryService { } } - async resolveAvailability(config: DeviceSignerConfig): Promise { + async resolveAvailability(config: DeviceSignerConfig, apiSigners?: APISigner[]): Promise { const deviceSignerKeyStorage = this.#options?.deviceSignerKeyStorage; if (deviceSignerKeyStorage == null) { throw new Error("Device signer key storage is required for device signers"); @@ -383,7 +389,11 @@ export class DeviceRecoveryService { return; } - const existingSigners = await this.#signers(); + // Same filter as signers(): only signers with an approval on this chain. + const existingSigners = + apiSigners != null + ? apiSigners.filter((s) => mapApiSignerToSigner(s, this.#chain) != null) + : await this.#signers(); const deviceSigners = existingSigners.filter((s) => s.locator.startsWith("device:")); for (const walletSigner of deviceSigners) { const publicKeyBase64 = walletSigner.locator.replace("device:", ""); diff --git a/packages/wallets/src/wallets/services/signer-manager.ts b/packages/wallets/src/wallets/services/signer-manager.ts index 810a84f15..70f96deac 100644 --- a/packages/wallets/src/wallets/services/signer-manager.ts +++ b/packages/wallets/src/wallets/services/signer-manager.ts @@ -1,6 +1,6 @@ import { ApiClientError } from "@crossmint/common-sdk-base"; -import type { ApiClient, GetSignerResponse, WalletLocator } from "../../api"; +import type { ApiClient, GetSignerResponse, Signer as APISigner, WalletLocator } from "../../api"; import type { Chain } from "../../chains/chains"; import { getSignerDescriptor, type SignerDescriptorContext } from "../../signers/descriptors"; import type { ServerSignerResolver } from "../../signers/server/resolver"; @@ -163,13 +163,23 @@ export class SignerManager { async assemble( internalConfig: InternalSignerConfig, - options?: { isAdminSigner?: boolean } + options?: { + isAdminSigner?: boolean; + /** + * Delegated signers as the API just returned them with the wallet. A signer listed here takes its + * status from that entry (the same object GET /signers/{locator} returns) instead of a new request. + */ + apiSigners?: APISigner[]; + } ): Promise { const signer = assembleSigner(this.#chain, internalConfig, this.#options?.deviceSignerKeyStorage); + const apiSigner = options?.apiSigners?.find((s) => s.locator === signer.locator()); if (options?.isAdminSigner) { // Admin signers are always approved for their wallet — skip the getSigner API call // which only works for delegated signers (returns 404/400 for admin signers). signer.status = "active"; + } else if (apiSigner != null) { + signer.status = mapApiSignerToSigner(apiSigner, this.#chain)?.status; } else { const signerState = await this.getSignerState(signer.locator()); signer.status = signerState.signer?.status; diff --git a/packages/wallets/src/wallets/wallet-factory.test.ts b/packages/wallets/src/wallets/wallet-factory.test.ts index 43eca9e04..2a118622c 100644 --- a/packages/wallets/src/wallets/wallet-factory.test.ts +++ b/packages/wallets/src/wallets/wallet-factory.test.ts @@ -1696,3 +1696,124 @@ describe("WalletFactory - recovery configuration rejected before any side effect }); }); }); + +describe("WalletFactory - signer initialization in getWallet", () => { + // getWallet awaits signer initialization. The wallet response already carries each delegated signer's + // per-chain status, so loading a wallet whose device key is on this device needs no request beyond GET wallet. + + const WALLET_ADDRESS = "0x1234567890123456789012345678901234567890"; + // a valid uncompressed P-256 public key: 0x04 + 32-byte x + 32-byte y + const DEVICE_KEY = Buffer.concat([Buffer.from([0x04]), Buffer.alloc(32, 1), Buffer.alloc(32, 2)]).toString( + "base64" + ); + const DEVICE_LOCATOR = `device:${DEVICE_KEY}`; + + const walletResponse = (deviceChains: Record) => + ({ + chainType: "evm", + type: "smart", + address: WALLET_ADDRESS, + owner: "email:user@example.com", + config: { + adminSigner: { type: "email", email: "user@example.com", locator: "email:user@example.com" }, + delegatedSigners: [{ type: "device", locator: DEVICE_LOCATOR, chains: deviceChains }], + }, + createdAt: Date.now(), + }) as unknown as GetWalletSuccessResponse; + + const keyStorage = (where: "mapped" | "unmapped") => ({ + preload: vi.fn(), + getKey: vi.fn().mockResolvedValue(where === "mapped" ? DEVICE_KEY : null), + hasKey: vi.fn().mockImplementation(async (key: string) => key === DEVICE_KEY), + mapAddressToKey: vi.fn().mockResolvedValue(undefined), + saveSignerLocator: vi.fn().mockResolvedValue(undefined), + generateKey: vi.fn(), + signMessage: vi.fn(), + deleteKey: vi.fn(), + getDeviceName: vi.fn().mockReturnValue("Test Device"), + }); + + let apiClient: { + isServerSide: boolean; + projectId: string; + environment: APIKeyEnvironmentPrefix; + getWallet: MockedFunction; + getSigner: MockedFunction; + }; + let factory: WalletFactory; + + beforeEach(() => { + vi.resetAllMocks(); + walletsLogger.debug = vi.fn(); + apiClient = { + isServerSide: false, + projectId: "test-project", + environment: APIKeyEnvironmentPrefix.STAGING, + getWallet: vi.fn(), + getSigner: vi.fn(), + }; + factory = new WalletFactory(apiClient as unknown as ApiClient); + }); + + it.each(["mapped", "unmapped"] as const)( + "uses this device's signer with its status from the wallet response, in one request (key %s)", + async (where) => { + apiClient.getWallet.mockResolvedValue(walletResponse({ "base-sepolia": { status: "success" } })); + const storage = keyStorage(where); + + const wallet = await factory.getWallet({ + chain: "base-sepolia", + options: { deviceSignerKeyStorage: storage as never }, + }); + + expect(apiClient.getWallet).toHaveBeenCalledTimes(1); + expect(apiClient.getSigner).not.toHaveBeenCalled(); + expect(wallet.signer?.locator()).toBe(DEVICE_LOCATOR); + expect(wallet.signer?.status).toBe("success"); + expect(wallet.needsRecovery()).toBe(false); + if (where === "unmapped") { + expect(storage.mapAddressToKey).toHaveBeenCalledWith(WALLET_ADDRESS, DEVICE_KEY); + } + } + ); + + it("needs recovery when the response shows this device's signer still awaiting approval", async () => { + apiClient.getWallet.mockResolvedValue( + walletResponse({ "base-sepolia": { status: "awaiting-approval", id: "sig-1" } }) + ); + + const wallet = await factory.getWallet({ + chain: "base-sepolia", + options: { deviceSignerKeyStorage: keyStorage("mapped") as never }, + }); + + expect(apiClient.getSigner).not.toHaveBeenCalled(); + expect(wallet.signer?.status).toBe("awaiting-approval"); + expect(wallet.needsRecovery()).toBe(true); + }); + + it("ignores a device signer approved only on another chain, like signers() does", async () => { + apiClient.getWallet.mockResolvedValue(walletResponse({ base: { status: "success" } })); + + const wallet = await factory.getWallet({ + chain: "base-sepolia", + options: { deviceSignerKeyStorage: keyStorage("unmapped") as never }, + }); + + expect(apiClient.getWallet).toHaveBeenCalledTimes(1); + expect(wallet.signer).toBeUndefined(); + expect(wallet.needsRecovery()).toBe(true); + }); + + it("starts loading the key storage before the wallet request returns", async () => { + const storage = keyStorage("mapped"); + apiClient.getWallet.mockImplementation(async () => { + expect(storage.preload).toHaveBeenCalledTimes(1); + return walletResponse({ "base-sepolia": { status: "success" } }); + }); + + await factory.getWallet({ chain: "base-sepolia", options: { deviceSignerKeyStorage: storage as never } }); + + expect(storage.preload).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/wallets/src/wallets/wallet-factory.ts b/packages/wallets/src/wallets/wallet-factory.ts index d6df8a63b..ebc740169 100644 --- a/packages/wallets/src/wallets/wallet-factory.ts +++ b/packages/wallets/src/wallets/wallet-factory.ts @@ -27,7 +27,7 @@ import type { RecoverySignerConfigForChain, SignerConfigForChain, } from "../signers/types"; -import { Wallet } from "./wallet"; +import { Wallet, type WalletFactoryConstructorArgs } from "./wallet"; import type { RecoverySignerConfigFor, WalletArgsFor, WalletCreateArgs } from "./types"; import { hasRecoveryMethodList, recoveryMethodsFromCreateArgs } from "../utils/recovery"; import { compareSignerConfigs, normalizeValueForComparison } from "../utils/signer-validation"; @@ -118,6 +118,10 @@ export class WalletFactory { walletsLogger.info("walletFactory.getWallet.start"); + // Signer initialization reads device keys right after the wallet loads: start loading the key + // storage (an iframe on web) now, alongside the request. + validatedArgs.options?.deviceSignerKeyStorage?.preload?.(); + const existingWallet = await this.apiClient.getWallet(walletLocator); if ("error" in existingWallet) { walletsLogger.warn("walletFactory.getWallet.notFound", { @@ -379,20 +383,19 @@ export class WalletFactory { .filter((s) => s.type === "server" && "address" in s && !("secret" in s)) .map((s) => (s as { address: string }).address); - const wallet = new Wallet( - { - chain: args.chain, - address: walletResponse.address, - owner: walletResponse.owner, - options: args.options, - alias: args.alias, - recovery: recoverySigners, - apiRecoveryServerSignerAddresses, - apiDelegatedServerSignerAddresses, - signers: (signers ?? []) as SignerConfigForChain[], - }, - this.apiClient - ); + const walletArgs: WalletFactoryConstructorArgs = { + chain: args.chain, + address: walletResponse.address, + owner: walletResponse.owner, + options: args.options, + alias: args.alias, + recovery: recoverySigners, + apiRecoveryServerSignerAddresses, + apiDelegatedServerSignerAddresses, + signers: (signers ?? []) as SignerConfigForChain[], + apiSigners: apiDelegatedSigners as SignerResponse[] | undefined, + }; + const wallet = new Wallet(walletArgs, this.apiClient); // Await signer initialization so that needsRecovery() returns the correct // value immediately after getWallet() / createWallet() resolves. diff --git a/packages/wallets/src/wallets/wallet.test.ts b/packages/wallets/src/wallets/wallet.test.ts index 7bc88346c..f3b3fe9c5 100644 --- a/packages/wallets/src/wallets/wallet.test.ts +++ b/packages/wallets/src/wallets/wallet.test.ts @@ -1775,231 +1775,98 @@ describe("Wallet - signers()", () => { }); describe("success cases", () => { - it("returns list of signers with status for EVM", async () => { - const mockWalletResponse: GetWalletSuccessResponse = { + function evmWalletWith(delegatedSigners: unknown[]): GetWalletSuccessResponse { + return { chainType: "evm", type: "smart", address: wallet.address, config: { - adminSigner: { - type: "api-key", - address: "0xadmin", - locator: "api-key:admin", - }, - delegatedSigners: [ - { - type: "external-wallet", - address: "0xsigner1", - locator: "external-wallet:0xsigner1", - }, - { - type: "external-wallet", - address: "0xsigner2", - locator: "external-wallet:0xsigner2", - }, - ], + adminSigner: { type: "api-key", address: "0xadmin", locator: "api-key:admin" }, + delegatedSigners, }, createdAt: Date.now(), } as GetWalletSuccessResponse; + } - mockApiClient.getWallet.mockResolvedValue(mockWalletResponse); + function externalWallet(n: number, chains: Record) { + return { + type: "external-wallet", + address: `0xsigner${n}`, + locator: `external-wallet:0xsigner${n}`, + chains, + }; + } - // Mock getSigner responses for each signer (EVM requires per-signer API calls) - mockApiClient.getSigner - .mockResolvedValueOnce({ - type: "external-wallet", - address: "0xsigner1", - locator: "external-wallet:0xsigner1", - chains: { - "base-sepolia": { status: "success" }, - }, - } as any) - .mockResolvedValueOnce({ - type: "external-wallet", - address: "0xsigner2", - locator: "external-wallet:0xsigner2", - chains: { - "base-sepolia": { status: "awaiting-approval", id: "sig-456" }, - }, - } as any); + it("returns each signer with its status on this chain, from the wallet request alone", async () => { + mockApiClient.getWallet.mockResolvedValue( + evmWalletWith([ + externalWallet(1, { "base-sepolia": { status: "success" } }), + externalWallet(2, { "base-sepolia": { status: "awaiting-approval", id: "sig-456" } }), + ]) + ); const signers = await wallet.signers(); - expect(signers).toHaveLength(2); - expect(signers[0].type).toBe("external-wallet"); - expect(signers[0].locator).toBe("external-wallet:0xsigner1"); - expect(signers[0].status).toBe("success"); - expect(signers[1].locator).toBe("external-wallet:0xsigner2"); - expect(signers[1].status).toBe("awaiting-approval"); + expect(signers.map((s) => [s.locator, s.status])).toEqual([ + ["external-wallet:0xsigner1", "success"], + ["external-wallet:0xsigner2", "awaiting-approval"], + ]); + expect(mockApiClient.getWallet).toHaveBeenCalledTimes(1); + expect(mockApiClient.getSigner).not.toHaveBeenCalled(); }); it("filters out signers without approval for current chain", async () => { - const mockWalletResponse: GetWalletSuccessResponse = { - chainType: "evm", - type: "smart", - address: wallet.address, - config: { - adminSigner: { - type: "api-key", - address: "0xadmin", - locator: "api-key:admin", - }, - delegatedSigners: [ - { - type: "external-wallet", - address: "0xsigner1", - locator: "external-wallet:0xsigner1", - }, - { - type: "external-wallet", - address: "0xsigner2", - locator: "external-wallet:0xsigner2", - }, - ], - }, - createdAt: Date.now(), - } as GetWalletSuccessResponse; - - mockApiClient.getWallet.mockResolvedValue(mockWalletResponse); - - // Signer1 has approval for base-sepolia, signer2 only has approval for ethereum (not base-sepolia) - mockApiClient.getSigner - .mockResolvedValueOnce({ - type: "external-wallet", - address: "0xsigner1", - locator: "external-wallet:0xsigner1", - chains: { - "base-sepolia": { status: "success" }, - }, - } as any) - .mockResolvedValueOnce({ - type: "external-wallet", - address: "0xsigner2", - locator: "external-wallet:0xsigner2", - chains: { - ethereum: { status: "success" }, - }, - } as any); + mockApiClient.getWallet.mockResolvedValue( + evmWalletWith([ + externalWallet(1, { "base-sepolia": { status: "success" } }), + externalWallet(2, { ethereum: { status: "success" } }), + ]) + ); const signers = await wallet.signers(); - expect(signers).toHaveLength(1); - expect(signers[0].locator).toBe("external-wallet:0xsigner1"); + expect(signers.map((s) => s.locator)).toEqual(["external-wallet:0xsigner1"]); }); it("returns empty array when no signers", async () => { - const mockWalletResponse: GetWalletSuccessResponse = { - chainType: "evm", - type: "smart", - address: wallet.address, - config: { - adminSigner: { - type: "api-key", - address: "0xadmin", - locator: "api-key:admin", - }, - }, - createdAt: Date.now(), - } as GetWalletSuccessResponse; - - mockApiClient.getWallet.mockResolvedValue(mockWalletResponse); + mockApiClient.getWallet.mockResolvedValue(evmWalletWith([])); const signers = await wallet.signers(); expect(signers).toHaveLength(0); }); - it("returns signer status for Solana from getSigner", async () => { + it("returns signer status for Solana from the signer's transaction", async () => { const solanaWallet = await createMockWallet("solana", mockApiClient); vi.mocked(solanaWallet.signers).mockRestore(); - const mockWalletResponse: GetWalletSuccessResponse = { + mockApiClient.getWallet.mockResolvedValue({ chainType: "solana", type: "smart", address: solanaWallet.address, config: { - adminSigner: { - type: "api-key", - address: "solana-admin", - locator: "api-key:admin", - }, + adminSigner: { type: "api-key", address: "solana-admin", locator: "api-key:admin" }, delegatedSigners: [ { type: "device", locator: "device:solana-device", publicKey: { x: "1", y: "2" }, + transaction: { + chainType: "solana", + id: "tx-123", + status: "pending", + onChain: { transaction: "serialized-tx" }, + }, }, ], }, createdAt: Date.now(), - } as GetWalletSuccessResponse; - - mockApiClient.getWallet.mockResolvedValue(mockWalletResponse); - mockApiClient.getSigner.mockResolvedValue({ - type: "device", - locator: "device:solana-device", - publicKey: { x: "1", y: "2" }, - transaction: { - chainType: "solana", - id: "tx-123", - status: "pending", - onChain: { - transaction: "serialized-tx", - }, - }, - } as any); + } as GetWalletSuccessResponse); const signers = await solanaWallet.signers(); - expect(signers).toHaveLength(1); - expect(signers[0].locator).toBe("device:solana-device"); - expect(signers[0].status).toBe("pending"); - }); - - it("returns all successes and filters out failures when some getSigner calls reject", async () => { - const configSigners = Array.from({ length: 12 }, (_, i) => ({ - type: "external-wallet", - address: `0xsigner${i}`, - locator: `external-wallet:0xsigner${i}`, - })); - - const mockWalletResponse: GetWalletSuccessResponse = { - chainType: "evm", - type: "smart", - address: wallet.address, - config: { - adminSigner: { - type: "api-key", - address: "0xadmin", - locator: "api-key:admin", - }, - delegatedSigners: configSigners, - }, - createdAt: Date.now(), - } as GetWalletSuccessResponse; - - mockApiClient.getWallet.mockResolvedValue(mockWalletResponse); - - configSigners.forEach((configSigner, i) => { - mockApiClient.getSigner.mockImplementationOnce(async () => { - if (i % 3 === 0) { - throw new Error("rate limited"); - } - return { - type: "external-wallet", - address: configSigner.address, - locator: configSigner.locator, - chains: { - "base-sepolia": { status: "success" }, - }, - } as any; - }); - }); - - const signers = await wallet.signers(); - - expect(signers).toHaveLength(8); - expect(mockApiClient.getSigner).toHaveBeenCalledTimes(12); + expect(signers.map((s) => [s.locator, s.status])).toEqual([["device:solana-device", "pending"]]); + expect(mockApiClient.getSigner).not.toHaveBeenCalled(); }); }); diff --git a/packages/wallets/src/wallets/wallet.ts b/packages/wallets/src/wallets/wallet.ts index d747b8009..beaa33056 100644 --- a/packages/wallets/src/wallets/wallet.ts +++ b/packages/wallets/src/wallets/wallet.ts @@ -9,6 +9,7 @@ import type { GetTransactionsResponse, GetWalletSuccessResponse, FundWalletResponse, + Signer as APISigner, } from "../api"; import type { AddSignerOptions, @@ -102,6 +103,8 @@ type WalletContructorType = { signer?: SignerAdapter; }; +export type WalletFactoryConstructorArgs = WalletContructorType & { apiSigners?: APISigner[] }; + export class Wallet { chain: C; address: string; @@ -129,7 +132,8 @@ export class Wallet { apiDelegatedServerSignerAddresses, signers, signer, - } = args; + apiSigners, + } = args as WalletFactoryConstructorArgs; this.#apiClient = apiClient; this.chain = chain; this.address = address; @@ -202,7 +206,7 @@ export class Wallet { includeSignatures: this.chainAdapter.supportsSignatures, }), }); - this.#signerInitialization = this.initDefaultSigner(); + this.#signerInitialization = this.initDefaultSigner(apiSigners); } public get signer(): SignerAdapter | undefined { @@ -229,13 +233,13 @@ export class Wallet { * Note: Server and api-key signers may fail to auto-assemble if required data * is not available in the API response. In those cases, the signer is left undefined. */ - private async initDefaultSigner(): Promise { + private async initDefaultSigner(apiSigners?: APISigner[]): Promise { // If useSigner has been called, don't try to auto-assemble a signer if (this.#signerManager.activeSigner != null) { return; } // Step 1: Try device signer (existing behavior) - await this.#deviceRecovery.initDeviceSigner(); + await this.#deviceRecovery.initDeviceSigner(apiSigners); // If device signer was found or recovery is pending, we're done if (this.#signerManager.activeSigner != null || this.#deviceRecovery.needsRecovery) { @@ -265,7 +269,9 @@ export class Wallet { signerToAssemble as SignerConfigForChain, signerDescriptorContext ); - this.#signerManager.setActiveSigner(await this.#signerManager.assemble(internalConfig, { isAdminSigner })); + this.#signerManager.setActiveSigner( + await this.#signerManager.assemble(internalConfig, { isAdminSigner, apiSigners }) + ); } catch (error) { walletsLogger.warn("wallet.initDefaultSigner.autoAssemblyFailed", { recoveryType: this.#signerManager.recovery.type, @@ -1288,22 +1294,11 @@ export class Wallet { throw new WalletTypeNotSupportedError(reason); } - const configSigners = walletResponse?.config?.delegatedSigners ?? []; - - const signersWithStatus = await Promise.all( - configSigners.map(async (configSigner) => { - try { - const signerState = await this.#signerManager.getSignerState(configSigner.locator as SignerLocator); - return signerState.signer; - } catch (error) { - walletsLogger.warn("wallet.signers.mapSigner.failed", { locator: configSigner.locator, error }); - return null; - } - }) - ); - - // Filter out null results (signers that don't have approval for this chain) - const signers = signersWithStatus.filter((s): s is WalletSigner => s != null); + // Each delegated signer in the wallet response is the same object GET signer returns, status included, + // so one request covers them all. Signers with no approval on this chain map to null. + const signers = ((walletResponse.config?.delegatedSigners ?? []) as APISigner[]) + .map((apiSigner) => mapApiSignerToSigner(apiSigner, this.chain)) + .filter((s): s is WalletSigner => s != null); walletsLogger.info("wallet.signers.success", { count: signers.length,