diff --git a/.changeset/evm-server-signer-normalization.md b/.changeset/evm-server-signer-normalization.md new file mode 100644 index 000000000..3544d6cc8 --- /dev/null +++ b/.changeset/evm-server-signer-normalization.md @@ -0,0 +1,11 @@ +--- +"@crossmint/wallets-sdk": minor +--- + +Normalize EVM server signer key derivation to use "evm" chain type + +- Add `deriveServerSignerCandidates` helper that returns both primary ("evm") and legacy (chain-specific) derivations +- Update `deriveServerSignerDetails` to use normalized "evm" chain type for all EVM chains +- Implement dual-derivation fallback in `useSigner`: try primary first, fall back to legacy for backward compatibility +- Update `isRecoverySigner` to match against either derivation +- Cache resolved server derivation in `buildInternalSignerConfig` for signing consistency diff --git a/packages/wallets/src/signers/server/helpers/derive-server-signer.ts b/packages/wallets/src/signers/server/helpers/derive-server-signer.ts index 059459051..d1d5a7982 100644 --- a/packages/wallets/src/signers/server/helpers/derive-server-signer.ts +++ b/packages/wallets/src/signers/server/helpers/derive-server-signer.ts @@ -8,6 +8,11 @@ import { deriveKeyBytes } from "../../../utils/server-key-derivation"; import { ed25519KeypairFromSeed, encodeStellarPublicKey } from "../../../utils/stellar"; import { getChainType } from "./get-chain-type"; +export type DerivedServerSigner = { + derivedKeyBytes: Uint8Array; + derivedAddress: string; +}; + export function deriveServerSignerAddress(keyBytes: Uint8Array, chain: Chain): string { const chainType = getChainType(chain); switch (chainType) { @@ -25,13 +30,43 @@ export function deriveServerSignerDetails( chain: Chain, projectId: string, environment: string -): { derivedKeyBytes: Uint8Array; derivedAddress: string } { +): DerivedServerSigner { if (typeof window !== "undefined") { throw new Error("Server signers can only be used from server-side code."); } - const chainStr = typeof chain === "string" ? chain : String(chain); - const derivedKeyBytes = deriveKeyBytes(signer.secret, projectId, environment, chainStr); + const chainType = getChainType(chain); + const derivedKeyBytes = deriveKeyBytes(signer.secret, projectId, environment, chainType); const derivedAddress = deriveServerSignerAddress(derivedKeyBytes, chain); return { derivedKeyBytes, derivedAddress }; } + +export function deriveServerSignerCandidates( + signer: ServerSignerConfig, + chain: Chain, + projectId: string, + environment: string +): { + primary: DerivedServerSigner; + legacy: DerivedServerSigner | null; +} { + if (typeof window !== "undefined") { + throw new Error("Server signers can only be used from server-side code."); + } + + const chainType = getChainType(chain); + + const primaryBytes = deriveKeyBytes(signer.secret, projectId, environment, chainType); + const primaryAddress = deriveServerSignerAddress(primaryBytes, chain); + const primary = { derivedKeyBytes: primaryBytes, derivedAddress: primaryAddress }; + + // Legacy: chain-specific derivation (only matters for EVM where chain !== chainType) + const chainStr = typeof chain === "string" ? chain : String(chain); + if (chainType === "evm" && chainStr !== "evm") { + const legacyBytes = deriveKeyBytes(signer.secret, projectId, environment, chainStr); + const legacyAddress = deriveServerSignerAddress(legacyBytes, chain); + return { primary, legacy: { derivedKeyBytes: legacyBytes, derivedAddress: legacyAddress } }; + } + + return { primary, legacy: null }; +} diff --git a/packages/wallets/src/signers/server/helpers/index.ts b/packages/wallets/src/signers/server/helpers/index.ts index a5e263eab..b762c2c2c 100644 --- a/packages/wallets/src/signers/server/helpers/index.ts +++ b/packages/wallets/src/signers/server/helpers/index.ts @@ -1,2 +1,7 @@ -export { deriveServerSignerAddress, deriveServerSignerDetails } from "./derive-server-signer"; +export { + type DerivedServerSigner, + deriveServerSignerAddress, + deriveServerSignerDetails, + deriveServerSignerCandidates, +} from "./derive-server-signer"; export { getChainType } from "./get-chain-type"; diff --git a/packages/wallets/src/signers/server/index.ts b/packages/wallets/src/signers/server/index.ts index 32afd7c26..ebac22ddc 100644 --- a/packages/wallets/src/signers/server/index.ts +++ b/packages/wallets/src/signers/server/index.ts @@ -2,4 +2,9 @@ export { assembleServerSigner } from "./assemble-server-signer"; export { EVMServerSigner } from "./evm-server-signer"; export { SolanaServerSigner } from "./solana-server-signer"; export { StellarServerSigner } from "./stellar-server-signer"; -export { deriveServerSignerAddress, deriveServerSignerDetails } from "./helpers"; +export { + type DerivedServerSigner, + deriveServerSignerAddress, + deriveServerSignerDetails, + deriveServerSignerCandidates, +} from "./helpers"; diff --git a/packages/wallets/src/signers/server/server-signers.test.ts b/packages/wallets/src/signers/server/server-signers.test.ts index 6e79ae5fe..af9ed5923 100644 --- a/packages/wallets/src/signers/server/server-signers.test.ts +++ b/packages/wallets/src/signers/server/server-signers.test.ts @@ -8,7 +8,12 @@ import type { ServerInternalSignerConfig } from "../types"; import { EVMServerSigner } from "./evm-server-signer"; import { SolanaServerSigner } from "./solana-server-signer"; import { StellarServerSigner } from "./stellar-server-signer"; -import { assembleServerSigner, deriveServerSignerAddress, deriveServerSignerDetails } from "./index"; +import { + assembleServerSigner, + deriveServerSignerAddress, + deriveServerSignerDetails, + deriveServerSignerCandidates, +} from "./index"; const TEST_SECRET = "a".repeat(64); const PROJECT_ID = "project-123"; @@ -187,6 +192,16 @@ describe("deriveServerSignerDetails", () => { expect(result.derivedAddress).toMatch(/^0x[0-9a-fA-F]{40}$/); }); + it("uses normalized 'evm' chain type for EVM chains", () => { + const config = { type: "server" as const, secret: TEST_SECRET }; + const baseSepolia = deriveServerSignerDetails(config, "base-sepolia", PROJECT_ID, ENVIRONMENT); + const ethereum = deriveServerSignerDetails(config, "ethereum", PROJECT_ID, ENVIRONMENT); + + // Both EVM chains should produce the same derivation (both use "evm") + expect(baseSepolia.derivedAddress).toBe(ethereum.derivedAddress); + expect(baseSepolia.derivedKeyBytes).toEqual(ethereum.derivedKeyBytes); + }); + it("throws when called in a browser environment", () => { const config = { type: "server" as const, secret: TEST_SECRET }; @@ -200,3 +215,53 @@ describe("deriveServerSignerDetails", () => { } }); }); + +describe("deriveServerSignerCandidates", () => { + const config = { type: "server" as const, secret: TEST_SECRET }; + + it("returns primary (evm) and legacy (chain-specific) for EVM chains", () => { + const result = deriveServerSignerCandidates(config, "base-sepolia", PROJECT_ID, ENVIRONMENT); + + expect(result.primary.derivedKeyBytes).toBeInstanceOf(Uint8Array); + expect(result.primary.derivedAddress).toMatch(/^0x[0-9a-fA-F]{40}$/); + expect(result.legacy).not.toBeNull(); + expect(result.legacy!.derivedKeyBytes).toBeInstanceOf(Uint8Array); + expect(result.legacy!.derivedAddress).toMatch(/^0x[0-9a-fA-F]{40}$/); + + // Primary and legacy should produce different addresses for EVM chains + expect(result.primary.derivedAddress).not.toBe(result.legacy!.derivedAddress); + }); + + it("returns null legacy for solana", () => { + const result = deriveServerSignerCandidates(config, "solana", PROJECT_ID, ENVIRONMENT); + + expect(result.primary.derivedKeyBytes).toBeInstanceOf(Uint8Array); + expect(result.legacy).toBeNull(); + }); + + it("returns null legacy for stellar", () => { + const result = deriveServerSignerCandidates(config, "stellar", PROJECT_ID, ENVIRONMENT); + + expect(result.primary.derivedKeyBytes).toBeInstanceOf(Uint8Array); + expect(result.legacy).toBeNull(); + }); + + it("primary matches deriveServerSignerDetails output", () => { + const candidates = deriveServerSignerCandidates(config, "base-sepolia", PROJECT_ID, ENVIRONMENT); + const details = deriveServerSignerDetails(config, "base-sepolia", PROJECT_ID, ENVIRONMENT); + + expect(candidates.primary.derivedAddress).toBe(details.derivedAddress); + expect(candidates.primary.derivedKeyBytes).toEqual(details.derivedKeyBytes); + }); + + it("throws when called in a browser environment", () => { + vi.stubGlobal("window", {}); + try { + expect(() => deriveServerSignerCandidates(config, "base-sepolia", PROJECT_ID, ENVIRONMENT)).toThrow( + "Server signers can only be used from server-side code." + ); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/packages/wallets/src/utils/server-key-derivation.test.ts b/packages/wallets/src/utils/server-key-derivation.test.ts index a5057b1ec..74d7fa3bf 100644 --- a/packages/wallets/src/utils/server-key-derivation.test.ts +++ b/packages/wallets/src/utils/server-key-derivation.test.ts @@ -34,6 +34,12 @@ describe("deriveKeyBytes", () => { expect(solana).not.toEqual(stellar); }); + it("produces different keys for 'evm' vs chain-specific strings like 'base-sepolia'", () => { + const evm = deriveKeyBytes(TEST_SECRET, PROJECT_ID, ENVIRONMENT, "evm"); + const baseSepolia = deriveKeyBytes(TEST_SECRET, PROJECT_ID, ENVIRONMENT, "base-sepolia"); + expect(evm).not.toEqual(baseSepolia); + }); + it("produces different keys for different environments", () => { const staging = deriveKeyBytes(TEST_SECRET, PROJECT_ID, "staging", "ethereum"); const production = deriveKeyBytes(TEST_SECRET, PROJECT_ID, "production", "ethereum"); diff --git a/packages/wallets/src/utils/server-key-derivation.ts b/packages/wallets/src/utils/server-key-derivation.ts index 6df1ece8a..a0e3ae02b 100644 --- a/packages/wallets/src/utils/server-key-derivation.ts +++ b/packages/wallets/src/utils/server-key-derivation.ts @@ -14,7 +14,8 @@ const SECRET_PREFIX = "xmsk1_"; * @param secret - Master secret (with or without xmsk1_ prefix), 64-char hex * @param projectId - Project ID from the API key * @param environment - Environment from the API key (staging, production) - * @param chain - Chain identifier (e.g., "base-sepolia", "ethereum") + * @param chain - Chain identifier. For new EVM derivations, pass "evm" (normalized). + * Legacy wallets may still use chain-specific strings (e.g., "base-sepolia", "ethereum"). * @returns Raw 32-byte derived key */ export function deriveKeyBytes(secret: string, projectId: string, environment: string, chain: string): Uint8Array { diff --git a/packages/wallets/src/wallets/evm.ts b/packages/wallets/src/wallets/evm.ts index 5768deff6..52a52ea05 100644 --- a/packages/wallets/src/wallets/evm.ts +++ b/packages/wallets/src/wallets/evm.ts @@ -15,7 +15,7 @@ import { Wallet } from "./wallet"; import type { Chain, EVMChain } from "../chains/chains"; import { InvalidTypedDataError, SignatureNotCreatedError, TransactionNotCreatedError } from "../utils/errors"; import type { CreateTransactionParams, CreateTransactionSuccessResponse } from "@/api"; -import { deriveServerSignerDetails } from "../signers/server"; +import type { ServerSignerConfig } from "../signers/types"; import { walletsLogger } from "../logger"; export class EVMWallet extends Wallet { @@ -28,6 +28,8 @@ export class EVMWallet extends Wallet { options: Wallet.getOptions(wallet), alias: wallet.alias, recovery: Wallet.getRecovery(wallet), + apiRecoveryServerSignerAddress: Wallet.getApiRecoveryServerSignerAddress(wallet), + apiDelegatedServerSignerAddresses: Wallet.getApiDelegatedServerSignerAddresses(wallet), signer: wallet.signer, signers: Wallet.getInitialSigners(wallet), }, @@ -222,7 +224,7 @@ export class EVMWallet extends Wallet { } else if (typeof options.signer === "string") { signer = options.signer; } else { - signer = `server:${deriveServerSignerDetails(options.signer, this.chain, this.apiClient.projectId, this.apiClient.environment).derivedAddress}`; + signer = this.resolveServerSignerApiLocator(options.signer as ServerSignerConfig); } const transactionCreationResponse = await this.apiClient.createTransaction(this.walletLocator, { params: { diff --git a/packages/wallets/src/wallets/solana.ts b/packages/wallets/src/wallets/solana.ts index 52a24aa1b..acf8cc725 100644 --- a/packages/wallets/src/wallets/solana.ts +++ b/packages/wallets/src/wallets/solana.ts @@ -12,7 +12,6 @@ import { Wallet } from "./wallet"; import { TransactionNotCreatedError } from "../utils/errors"; import { SolanaExternalWalletSigner } from "@/signers/solana-external-wallet"; import type { CreateTransactionSuccessResponse } from "@/api"; -import { deriveServerSignerDetails } from "../signers/server"; import { walletsLogger } from "../logger"; export class SolanaWallet extends Wallet { @@ -25,6 +24,8 @@ export class SolanaWallet extends Wallet { options: Wallet.getOptions(wallet), alias: wallet.alias, recovery: Wallet.getRecovery(wallet), + apiRecoveryServerSignerAddress: Wallet.getApiRecoveryServerSignerAddress(wallet), + apiDelegatedServerSignerAddresses: Wallet.getApiDelegatedServerSignerAddresses(wallet), signer: wallet.signer, signers: Wallet.getInitialSigners(wallet), }, @@ -103,7 +104,7 @@ export class SolanaWallet extends Wallet { } else if (typeof params.options.signer === "string") { signer = params.options.signer; } else { - signer = `server:${deriveServerSignerDetails(params.options.signer, this.chain, this.apiClient.projectId, this.apiClient.environment).derivedAddress}`; + signer = this.resolveServerSignerApiLocator(params.options.signer); } let serializedTransaction: string; diff --git a/packages/wallets/src/wallets/stellar.ts b/packages/wallets/src/wallets/stellar.ts index e6d08bdea..1c6b8d11d 100644 --- a/packages/wallets/src/wallets/stellar.ts +++ b/packages/wallets/src/wallets/stellar.ts @@ -12,7 +12,6 @@ import type { import { Wallet } from "./wallet"; import { TransactionNotCreatedError } from "../utils/errors"; import type { CreateTransactionSuccessResponse } from "@/api"; -import { deriveServerSignerDetails } from "../signers/server"; import { walletsLogger } from "../logger"; import type { ServerSignerConfig } from "../signers/types"; @@ -28,6 +27,8 @@ export class StellarWallet extends Wallet { options: Wallet.getOptions(wallet), alias: wallet.alias, recovery: Wallet.getRecovery(wallet), + apiRecoveryServerSignerAddress: Wallet.getApiRecoveryServerSignerAddress(wallet), + apiDelegatedServerSignerAddresses: Wallet.getApiDelegatedServerSignerAddresses(wallet), signer: wallet.signer, signers: Wallet.getInitialSigners(wallet), }, @@ -274,7 +275,7 @@ export class StellarWallet extends Wallet { if (typeof signerOverride === "string") { return signerOverride; } - return `server:${deriveServerSignerDetails(signerOverride, this.chain, this.apiClient.projectId, this.apiClient.environment).derivedAddress}`; + return this.resolveServerSignerApiLocator(signerOverride); } } diff --git a/packages/wallets/src/wallets/wallet-factory.ts b/packages/wallets/src/wallets/wallet-factory.ts index a25617692..be4869f00 100644 --- a/packages/wallets/src/wallets/wallet-factory.ts +++ b/packages/wallets/src/wallets/wallet-factory.ts @@ -23,7 +23,7 @@ import { Wallet } from "./wallet"; import type { WalletArgsFor, WalletCreateArgs } from "./types"; import { compareSignerConfigs, normalizeValueForComparison } from "../utils/signer-validation"; import { getSignerLocator } from "../utils/signer-locator"; -import { deriveServerSignerDetails } from "../signers/server"; +import { deriveServerSignerDetails, deriveServerSignerCandidates } from "../signers/server"; import type { DeviceSignerKeyStorage } from "@/utils/device-signers/DeviceSignerKeyStorage"; import { createDeviceSigner } from "@/utils/device-signers"; @@ -195,7 +195,8 @@ export class WalletFactory { ? createArgs.recovery : apiRecovery; - let signers = (walletResponse.config as SmartWalletConfig).delegatedSigners; + const apiDelegatedSigners = (walletResponse.config as SmartWalletConfig).delegatedSigners; + let signers = apiDelegatedSigners; if ( signers != null && signers.length === 1 && @@ -204,6 +205,19 @@ export class WalletFactory { signers = createArgs.signers as SignerResponse[]; } + // Preserve the API-sourced server signer recovery address so the wallet can identify + // legacy derivations even when the user-provided config replaces the API one. + const apiRecoveryServerSignerAddress = + apiRecovery.type === "server" && "address" in apiRecovery && !("secret" in apiRecovery) + ? (apiRecovery as { address: string }).address + : undefined; + + // Preserve the API-sourced server signer delegated addresses so the wallet can identify + // legacy derivations even when the user-provided config replaces the API one. + const apiDelegatedServerSignerAddresses = (apiDelegatedSigners ?? []) + .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, @@ -212,6 +226,8 @@ export class WalletFactory { options: args.options, alias: args.alias, recovery, + apiRecoveryServerSignerAddress, + apiDelegatedServerSignerAddresses, signers: (signers ?? []) as SignerConfigForChain[], }, this.apiClient @@ -342,13 +358,16 @@ export class WalletFactory { return true; } if (inputSigner.type === "server") { - const { derivedAddress } = deriveServerSignerDetails( + const { primary, legacy } = deriveServerSignerCandidates( inputSigner, chain, this.apiClient.projectId, this.apiClient.environment ); - return existingSigner.locator === `server:${derivedAddress}`; + return ( + existingSigner.locator === `server:${primary.derivedAddress}` || + (legacy != null && existingSigner.locator === `server:${legacy.derivedAddress}`) + ); } return existingSigner.locator === getSignerLocator(inputSigner); }); diff --git a/packages/wallets/src/wallets/wallet.test.ts b/packages/wallets/src/wallets/wallet.test.ts index 39ecb58b2..a010e0465 100644 --- a/packages/wallets/src/wallets/wallet.test.ts +++ b/packages/wallets/src/wallets/wallet.test.ts @@ -23,6 +23,10 @@ vi.mock("@/signers/server", async (importOriginal) => { derivedKeyBytes: new Uint8Array(32), derivedAddress: "0xDerivedServerAddress", }), + deriveServerSignerCandidates: vi.fn().mockReturnValue({ + primary: { derivedKeyBytes: new Uint8Array(32), derivedAddress: "0xDerivedServerAddress" }, + legacy: { derivedKeyBytes: new Uint8Array(32).fill(1), derivedAddress: "0xLegacyServerAddress" }, + }), assembleServerSigner: vi.fn().mockReturnValue({ type: "server", locator: () => "server:0xDerivedServerAddress", @@ -1710,12 +1714,19 @@ describe("Wallet - useSigner()", () => { }); it("addSigner should succeed after useSigner with matching server signer and API-sourced recovery", async () => { - const { deriveServerSignerDetails, assembleServerSigner } = await import("@/signers/server"); + const { deriveServerSignerDetails, deriveServerSignerCandidates, assembleServerSigner } = await import( + "@/signers/server" + ); const mockedDerive = vi.mocked(deriveServerSignerDetails); mockedDerive.mockReturnValue({ derivedKeyBytes: new Uint8Array(32), derivedAddress: "0xRecoveryAddress", }); + const mockedCandidates = vi.mocked(deriveServerSignerCandidates); + mockedCandidates.mockReturnValue({ + primary: { derivedKeyBytes: new Uint8Array(32), derivedAddress: "0xRecoveryAddress" }, + legacy: null, + }); // Override assembleServerSigner so the assembled signer address matches recovery const mockedAssemble = vi.mocked(assembleServerSigner); mockedAssemble.mockReturnValue({ @@ -1737,17 +1748,6 @@ describe("Wallet - useSigner()", () => { mockApiClient as unknown as ApiClient ); vi.spyOn(wallet, "signers").mockResolvedValue([]); - mockApiClient.getSigner.mockImplementation((_walletLocator: string, signerLocator: string) => { - if (signerLocator === "server:0xRecoveryAddress") { - return Promise.resolve({ - type: "server", - address: "0xRecoveryAddress", - locator: "server:0xRecoveryAddress", - chains: { "base-sepolia": { status: "active", id: "sig-server" } }, - }); - } - return Promise.reject(new Error("Signer not found")); - }); // Set the matching recovery server signer await wallet.useSigner({ type: "server", secret: "recovery-secret" } as any); @@ -1953,6 +1953,161 @@ describe("Wallet - useSigner()", () => { ); }); }); + + describe("server signer EVM normalization fallback", () => { + it("useSigner works when only the primary (evm) derivation is registered", async () => { + const { deriveServerSignerCandidates } = await import("@/signers/server"); + const mockedCandidates = vi.mocked(deriveServerSignerCandidates); + mockedCandidates.mockReturnValue({ + primary: { derivedKeyBytes: new Uint8Array(32), derivedAddress: "0xPrimaryAddress" }, + legacy: { derivedKeyBytes: new Uint8Array(32).fill(1), derivedAddress: "0xLegacyAddress" }, + }); + + mockApiClient = createMockApiClient(); + const wallet = new Wallet( + { + chain: "base-sepolia" as const, + address: "0x1234567890123456789012345678901234567890", + recovery: { type: "api-key" } as any, + }, + mockApiClient as unknown as ApiClient + ); + // Primary is registered in the signers list + vi.spyOn(wallet, "signers").mockResolvedValue([ + { + type: "server", + address: "0xPrimaryAddress", + locator: "server:0xPrimaryAddress", + status: "success" as const, + } as any, + ]); + + await wallet.useSigner({ type: "server", secret: "test-secret" } as any); + + expect(wallet.signer).toBeDefined(); + expect(wallet.signer?.type).toBe("server"); + }); + + it("useSigner falls back to legacy derivation when primary is not registered", async () => { + const { deriveServerSignerCandidates, assembleServerSigner } = await import("@/signers/server"); + const mockedCandidates = vi.mocked(deriveServerSignerCandidates); + mockedCandidates.mockReturnValue({ + primary: { derivedKeyBytes: new Uint8Array(32), derivedAddress: "0xPrimaryAddress" }, + legacy: { derivedKeyBytes: new Uint8Array(32).fill(1), derivedAddress: "0xLegacyAddress" }, + }); + const mockedAssemble = vi.mocked(assembleServerSigner); + mockedAssemble.mockReturnValue({ + type: "server", + locator: () => "server:0xLegacyAddress", + address: () => "0xLegacyAddress", + status: undefined, + signMessage: vi.fn().mockResolvedValue({ signature: "0xmocksig" }), + signTransaction: vi.fn().mockResolvedValue({ signature: "0xmocksig" }), + } as any); + + mockApiClient = createMockApiClient(); + const wallet = new Wallet( + { + chain: "base-sepolia" as const, + address: "0x1234567890123456789012345678901234567890", + recovery: { type: "api-key" } as any, + }, + mockApiClient as unknown as ApiClient + ); + // Only legacy is in the signers list (not primary) + vi.spyOn(wallet, "signers").mockResolvedValue([ + { + type: "server", + address: "0xLegacyAddress", + locator: "server:0xLegacyAddress", + status: "success" as const, + } as any, + ]); + + await wallet.useSigner({ type: "server", secret: "test-secret" } as any); + + expect(wallet.signer).toBeDefined(); + expect(wallet.signer?.type).toBe("server"); + }); + + it("isRecoverySigner matches when recovery address matches legacy derivation and uses legacy key", async () => { + const { deriveServerSignerCandidates, assembleServerSigner } = await import("@/signers/server"); + const mockedCandidates = vi.mocked(deriveServerSignerCandidates); + mockedCandidates.mockReturnValue({ + primary: { derivedKeyBytes: new Uint8Array(32), derivedAddress: "0xPrimaryAddress" }, + legacy: { derivedKeyBytes: new Uint8Array(32).fill(1), derivedAddress: "0xLegacyAddress" }, + }); + const mockedAssemble = vi.mocked(assembleServerSigner); + mockedAssemble.mockReturnValue({ + type: "server", + locator: () => "server:0xLegacyAddress", + address: () => "0xLegacyAddress", + status: undefined, + signMessage: vi.fn().mockResolvedValue({ signature: "0xmocksig" }), + signTransaction: vi.fn().mockResolvedValue({ signature: "0xmocksig" }), + } as any); + + mockApiClient = createMockApiClient(); + // Recovery was registered with the legacy address + const wallet = new Wallet( + { + chain: "base-sepolia" as const, + address: "0x1234567890123456789012345678901234567890", + recovery: { type: "server", address: "0xLegacyAddress" } as ApiSourcedServerSignerConfig, + }, + mockApiClient as unknown as ApiClient + ); + vi.spyOn(wallet, "signers").mockResolvedValue([]); + + // Neither primary nor legacy registered as delegated — falls through to isRecoverySigner + mockApiClient.getSigner.mockRejectedValue(new Error("Signer not found")); + + // Should succeed because legacy derivation matches the API-sourced recovery address + await wallet.useSigner({ type: "server", secret: "test-secret" } as any); + + expect(wallet.signer).toBeDefined(); + expect(wallet.signer?.type).toBe("server"); + // The signer should use the legacy address (not primary), since that matches on-chain + expect(wallet.signer?.address()).toBe("0xLegacyAddress"); + }); + + it("recovery server signer uses primary derivation when recovery address matches primary", async () => { + const { deriveServerSignerCandidates, assembleServerSigner } = await import("@/signers/server"); + const mockedCandidates = vi.mocked(deriveServerSignerCandidates); + mockedCandidates.mockReturnValue({ + primary: { derivedKeyBytes: new Uint8Array(32), derivedAddress: "0xPrimaryAddress" }, + legacy: { derivedKeyBytes: new Uint8Array(32).fill(1), derivedAddress: "0xLegacyAddress" }, + }); + const mockedAssemble = vi.mocked(assembleServerSigner); + mockedAssemble.mockReturnValue({ + type: "server", + locator: () => "server:0xPrimaryAddress", + address: () => "0xPrimaryAddress", + status: undefined, + signMessage: vi.fn().mockResolvedValue({ signature: "0xmocksig" }), + signTransaction: vi.fn().mockResolvedValue({ signature: "0xmocksig" }), + } as any); + + mockApiClient = createMockApiClient(); + // Recovery was registered with the primary (evm) address + const wallet = new Wallet( + { + chain: "base-sepolia" as const, + address: "0x1234567890123456789012345678901234567890", + recovery: { type: "server", address: "0xPrimaryAddress" } as ApiSourcedServerSignerConfig, + }, + mockApiClient as unknown as ApiClient + ); + vi.spyOn(wallet, "signers").mockResolvedValue([]); + mockApiClient.getSigner.mockRejectedValue(new Error("Signer not found")); + + await wallet.useSigner({ type: "server", secret: "test-secret" } as any); + + expect(wallet.signer).toBeDefined(); + expect(wallet.signer?.type).toBe("server"); + expect(wallet.signer?.address()).toBe("0xPrimaryAddress"); + }); + }); }); describe("Wallet - recover()", () => { diff --git a/packages/wallets/src/wallets/wallet.ts b/packages/wallets/src/wallets/wallet.ts index b1f79f1dc..3071ab25c 100644 --- a/packages/wallets/src/wallets/wallet.ts +++ b/packages/wallets/src/wallets/wallet.ts @@ -68,7 +68,10 @@ import type { import { type ApiSourcedServerSignerConfig, isApiSourcedServerSignerConfig, AuthRejectedError } from "../signers/types"; import { assembleSigner } from "../signers"; import { NonCustodialSigner } from "../signers/non-custodial"; -import { deriveServerSignerDetails } from "../signers/server"; +import { + type DerivedServerSigner, + deriveServerSignerCandidates as deriveServerSignerCandidatesHelper, +} from "../signers/server"; import { walletsLogger } from "../logger"; import { getSignerLocator } from "../utils/signer-locator"; @@ -82,6 +85,8 @@ type WalletContructorType = { alias?: string; options?: WalletOptions; recovery: RecoverySignerConfigForChain; + apiRecoveryServerSignerAddress?: string; + apiDelegatedServerSignerAddresses?: string[]; signers?: SignerConfigForChain[]; signer?: SignerAdapter; }; @@ -98,11 +103,25 @@ export class Wallet { #initialSigners: SignerConfigForChain[]; #needsRecovery = false; #deviceSignerApproved = false; + #resolvedServerSigner: DerivedServerSigner | null = null; + #apiRecoveryServerSignerAddress: string | null = null; + #apiDelegatedServerSignerAddresses: string[] = []; #signerInitialization: Promise; #recovering: Promise | null = null; constructor(args: WalletContructorType, apiClient: ApiClient) { - const { chain, address, owner, options, alias, recovery, signers, signer } = args; + const { + chain, + address, + owner, + options, + alias, + recovery, + apiRecoveryServerSignerAddress, + apiDelegatedServerSignerAddresses, + signers, + signer, + } = args; this.#apiClient = apiClient; this.chain = chain; this.address = address; @@ -110,6 +129,12 @@ export class Wallet { this.#options = options; this.alias = alias; this.#recovery = recovery; + if (apiRecoveryServerSignerAddress != null) { + this.#apiRecoveryServerSignerAddress = apiRecoveryServerSignerAddress; + } else if (recovery.type === "server" && isApiSourcedServerSignerConfig(recovery)) { + this.#apiRecoveryServerSignerAddress = recovery.address; + } + this.#apiDelegatedServerSignerAddresses = apiDelegatedServerSignerAddresses ?? []; this.#initialSigners = signers ?? []; this.#signer = signer; // Can be set by useSigner this.#signerInitialization = this.initDefaultSigner(); @@ -244,6 +269,14 @@ export class Wallet { return wallet.#initialSigners; } + protected static getApiRecoveryServerSignerAddress(wallet: Wallet): string | undefined { + return wallet.#apiRecoveryServerSignerAddress ?? undefined; + } + + protected static getApiDelegatedServerSignerAddresses(wallet: Wallet): string[] { + return wallet.#apiDelegatedServerSignerAddresses; + } + public get apiClient(): ApiClient { return this.#apiClient; } @@ -252,6 +285,69 @@ export class Wallet { return this.#options; } + /** + * Derive both primary ("evm") and legacy (chain-specific) server signer candidates. + */ + private deriveServerSignerCandidates(signer: ServerSignerConfig) { + return deriveServerSignerCandidatesHelper( + signer, + this.chain, + this.#apiClient.projectId, + this.#apiClient.environment + ); + } + + /** + * Return the resolved server signer if it matches the given config (i.e. its address + * matches either the primary or legacy candidate). Returns null otherwise. + */ + private matchResolvedServerSigner(signer: ServerSignerConfig): DerivedServerSigner | null { + if (this.#resolvedServerSigner == null) { + return null; + } + const { primary, legacy } = this.deriveServerSignerCandidates(signer); + const cachedAddr = this.#resolvedServerSigner.derivedAddress; + if (cachedAddr === primary.derivedAddress || (legacy != null && cachedAddr === legacy.derivedAddress)) { + return this.#resolvedServerSigner; + } + return null; + } + + /** + * Resolve which derivation (primary "evm" or legacy chain-specific) to use for a server signer. + * Priority: cached resolution → legacy if it matches a known on-chain address → primary. + */ + private resolveServerSignerDerivation(signer: ServerSignerConfig): DerivedServerSigner { + const resolved = this.matchResolvedServerSigner(signer); + if (resolved != null) { + return resolved; + } + const { primary, legacy } = this.deriveServerSignerCandidates(signer); + if (legacy != null) { + // Use legacy if it matches the recovery address or any known on-chain signer + if (legacy.derivedAddress === this.#apiRecoveryServerSignerAddress) { + return legacy; + } + if ( + this.#initialSigners.some( + (s) => + s.type === "server" && isApiSourcedServerSignerConfig(s) && s.address === legacy.derivedAddress + ) || + this.#apiDelegatedServerSignerAddresses.includes(legacy.derivedAddress) + ) { + return legacy; + } + } + return primary; + } + + /** + * Resolve a ServerSignerConfig to an API locator string. + */ + protected resolveServerSignerApiLocator(signer: ServerSignerConfig): string { + return `server:${this.resolveServerSignerDerivation(signer).derivedAddress}`; + } + /** * Get the recovery signer config * @returns The recovery signer config @@ -532,7 +628,7 @@ export class Wallet { } else if (typeof options.signer === "string") { signer = options.signer; } else { - signer = `server:${deriveServerSignerDetails(options.signer, this.chain, this.#apiClient.projectId, this.#apiClient.environment).derivedAddress}`; + signer = this.resolveServerSignerApiLocator(options.signer); } const sendParams = { @@ -662,7 +758,7 @@ export class Wallet { // Resolve server signer config to locator string const resolvedSigner = typeof signer === "object" && "type" in signer && signer.type === "server" - ? (`server:${deriveServerSignerDetails(signer, this.chain, this.#apiClient.projectId, this.#apiClient.environment).derivedAddress}` as const) + ? (this.resolveServerSignerApiLocator(signer) as `server:${string}`) : signer; return this.withRecoverySigner(async () => { @@ -884,6 +980,12 @@ export class Wallet { }) public async useSigner(signer: SignerConfigForChain): Promise { walletsLogger.info("wallet.useSigner.start"); + // Only reset when processing a fresh server signer; for other signer types the cached + // derivation must be preserved so that withRecoverySigner (addSigner / removeSigner) + // continues to use the correct primary-or-legacy key for a server recovery signer. + if (signer.type === "server") { + this.#resolvedServerSigner = null; + } this.validateSignerInput(signer); let isAdminSigner = false; @@ -905,12 +1007,14 @@ export class Wallet { * Returns true if the signer is an admin (recovery) signer. */ private async resolveNonDeviceSigner(signer: SignerConfigForChain): Promise { - // For non-passkey signers, check if this is the recovery (admin) signer first. + // For non-passkey, non-server signers, check if this is the recovery (admin) signer first. // Admin signers are always approved — skip the registration check and getSigner API call // which only works for delegated signers (returns 404/400 for admin signers). // Passkeys are excluded because isRecoverySigner matches by type only, which could // incorrectly match a delegated passkey. - if (signer.type !== "passkey" && this.isRecoverySigner(signer)) { + // Server signers are excluded so they always flow through the server signer block below, + // which sets #resolvedServerSigner to the correct (primary or legacy) key. + if (signer.type !== "passkey" && signer.type !== "server" && this.isRecoverySigner(signer)) { this.#needsRecovery = false; return true; } @@ -928,6 +1032,10 @@ export class Wallet { } } + if (signer.type === "server") { + return this.resolveServerSigner(signer); + } + // Check if this is a registered signer const locator = this.resolveSignerLocator(signer); if (await this.signerIsRegistered(locator)) { @@ -944,6 +1052,48 @@ export class Wallet { throw new Error(`Signer "${locator}" is not registered in this wallet.`); } + /** + * Resolve a server signer: try primary ("evm") derivation first, fall back to legacy + * (chain-specific) derivation when checking registration. Sets #resolvedServerSigner + * to whichever derivation is on-chain. Returns true if the signer is the admin (recovery) signer. + */ + private async resolveServerSigner(signer: ServerSignerConfig): Promise { + const { primary, legacy } = this.deriveServerSignerCandidates(signer); + const existingSigners = await this.signers(); + if (existingSigners.some((s) => s.locator === `server:${primary.derivedAddress}`)) { + this.#resolvedServerSigner = primary; + this.#needsRecovery = false; + return false; + } + if (legacy != null && existingSigners.some((s) => s.locator === `server:${legacy.derivedAddress}`)) { + this.#resolvedServerSigner = legacy; + this.#needsRecovery = false; + return false; + } + // Neither found as delegated — check if this is the recovery (admin) signer. + if (this.isRecoverySigner(signer as SignerConfigForChain)) { + // Resolve which derivation matches the on-chain recovery address. + // #apiRecoveryServerSignerAddress is captured once from the original API response + // and survives across isRecoverySigner upgrades and repeated useSigner calls. + if ( + this.#apiRecoveryServerSignerAddress != null && + legacy != null && + legacy.derivedAddress === this.#apiRecoveryServerSignerAddress + ) { + this.#resolvedServerSigner = legacy; + } else { + this.#resolvedServerSigner = primary; + } + this.#needsRecovery = false; + return true; + } + const tried = + legacy != null + ? `"server:${primary.derivedAddress}" or "server:${legacy.derivedAddress}"` + : `"server:${primary.derivedAddress}"`; + throw new Error(`Signer ${tried} is not registered in this wallet.`); + } + /** * Try to auto-select a passkey credential from registered signers. * Returns true if a credential was auto-selected, false if no passkey signers exist. @@ -972,13 +1122,7 @@ export class Wallet { */ private resolveSignerLocator(signer: SignerConfigForChain | ExternalWalletRegistrationConfig): string { if (signer.type === "server") { - const { derivedAddress } = deriveServerSignerDetails( - signer, - this.chain, - this.#apiClient.projectId, - this.#apiClient.environment - ); - return `server:${derivedAddress}`; + return this.resolveServerSignerApiLocator(signer); } return getSignerLocator(signer); } @@ -1461,20 +1605,24 @@ export class Wallet { // For server signers, the API-sourced recovery config has no secret, so we // can't derive a locator from it. Compare using the address field instead. if (signerConfig.type === "server" && recovery.type === "server") { - const resolveAddress = (config: ServerSignerConfig | ApiSourcedServerSignerConfig) => - isApiSourcedServerSignerConfig(config) - ? config.address - : deriveServerSignerDetails( - config, - this.chain, - this.#apiClient.projectId, - this.#apiClient.environment - ).derivedAddress; - - const signerAddress = resolveAddress(signerConfig); - const recoveryAddress = resolveAddress(recovery); - - if (signerAddress !== recoveryAddress) { + const resolveAddresses = (config: ServerSignerConfig | ApiSourcedServerSignerConfig): string[] => { + if (isApiSourcedServerSignerConfig(config)) { + return [config.address]; + } + const { primary, legacy } = this.deriveServerSignerCandidates(config); + const addresses = [primary.derivedAddress]; + if (legacy != null) { + addresses.push(legacy.derivedAddress); + } + return addresses; + }; + + const signerAddresses = resolveAddresses(signerConfig); + const recoveryAddresses = resolveAddresses(recovery); + + // Match if any signer address matches any recovery address + const matches = signerAddresses.some((a) => recoveryAddresses.includes(a)); + if (!matches) { return false; } } else { @@ -1659,12 +1807,7 @@ export class Wallet { address: this.address, } as InternalSignerConfig; case "server": { - const { derivedKeyBytes, derivedAddress } = deriveServerSignerDetails( - config, - this.chain, - this.#apiClient.projectId, - this.#apiClient.environment - ); + const { derivedKeyBytes, derivedAddress } = this.resolveServerSignerDerivation(config); return { type: "server", derivedKeyBytes,