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
7 changes: 7 additions & 0 deletions .changeset/wallet-init-fewer-requests.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ export abstract class DeviceSignerKeyStorage {
*/
abstract deleteKey(address: string): Promise<void>;

/**
* 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
20 changes: 15 additions & 5 deletions packages/wallets/src/wallets/services/device-recovery-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -81,7 +83,11 @@ export class DeviceRecoveryService<C extends Chain> {
}
}

async initDeviceSigner(): Promise<void> {
/**
* @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<void> {
const deviceSignerKeyStorage = this.#options?.deviceSignerKeyStorage;
if (deviceSignerKeyStorage == null) {
return;
Expand All @@ -92,7 +98,7 @@ export class DeviceRecoveryService<C extends Chain> {

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";
Expand All @@ -107,7 +113,7 @@ export class DeviceRecoveryService<C extends Chain> {
deviceConfig as SignerConfigForChain<C>,
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)) {
Expand Down Expand Up @@ -370,7 +376,7 @@ export class DeviceRecoveryService<C extends Chain> {
}
}

async resolveAvailability(config: DeviceSignerConfig): Promise<void> {
async resolveAvailability(config: DeviceSignerConfig, apiSigners?: APISigner[]): Promise<void> {
const deviceSignerKeyStorage = this.#options?.deviceSignerKeyStorage;
if (deviceSignerKeyStorage == null) {
throw new Error("Device signer key storage is required for device signers");
Expand All @@ -383,7 +389,11 @@ export class DeviceRecoveryService<C extends Chain> {
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:", "");
Expand Down
14 changes: 12 additions & 2 deletions packages/wallets/src/wallets/services/signer-manager.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -163,13 +163,23 @@ export class SignerManager<C extends Chain> {

async assemble(
internalConfig: InternalSignerConfig<C>,
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<SignerAdapter> {
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;
Expand Down
121 changes: 121 additions & 0 deletions packages/wallets/src/wallets/wallet-factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { status: string; id?: string }>) =>
({
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<ApiClient["getWallet"]>;
getSigner: MockedFunction<ApiClient["getSigner"]>;
};
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);
});
});
33 changes: 18 additions & 15 deletions packages/wallets/src/wallets/wallet-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", {
Expand Down Expand Up @@ -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<C>[],
},
this.apiClient
);
const walletArgs: WalletFactoryConstructorArgs<C> = {
chain: args.chain,
address: walletResponse.address,
owner: walletResponse.owner,
options: args.options,
alias: args.alias,
recovery: recoverySigners,
apiRecoveryServerSignerAddresses,
apiDelegatedServerSignerAddresses,
signers: (signers ?? []) as SignerConfigForChain<C>[],
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.
Expand Down
Loading
Loading