Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ebb7fd1
feat(wallets): normalize EVM server signer derivation to use 'evm' ch…
devin-ai-integration[bot] May 22, 2026
fb328f0
fix(wallets): set #resolvedServerDerivation for server recovery signe…
devin-ai-integration[bot] May 22, 2026
4ebcd27
fix(wallets): address review feedback — validateSigners dual-derivati…
devin-ai-integration[bot] May 22, 2026
42d8466
fix(wallets): store API-sourced recovery address for repeated useSign…
devin-ai-integration[bot] May 22, 2026
b8198eb
fix(wallets): remove cache from resolveSignerLocator to prevent cross…
devin-ai-integration[bot] May 22, 2026
f24e5f4
fix(wallets): restore cache in resolveSignerLocator for legacy wallet…
devin-ai-integration[bot] May 22, 2026
df4d9da
fix(wallets): restore cache in resolveServerSignerApiLocator for lega…
devin-ai-integration[bot] May 22, 2026
d109ad9
fix(wallets): validate cache ownership before using it — prevent cros…
devin-ai-integration[bot] May 22, 2026
81ab88a
fix(wallets): pass API recovery address from factory to wallet constr…
devin-ai-integration[bot] May 22, 2026
0a43d99
fix(wallets): propagate apiRecoveryAddress through all wallet subclas…
devin-ai-integration[bot] May 22, 2026
8f39cac
fix(wallets): include both primary and legacy addresses in unregister…
devin-ai-integration[bot] May 22, 2026
b9a06a0
refactor(wallets): extract resolveServerSigner and deduplicate deriva…
devin-ai-integration[bot] May 25, 2026
9d7e1fc
fix(wallets): fix type error in resolveServerSigner parameter type
devin-ai-integration[bot] May 25, 2026
9e83f1e
fix(wallets): use type assertion for isRecoverySigner in resolveServe…
devin-ai-integration[bot] May 25, 2026
d2d15d1
refactor(wallets): address PR review - rename helpers, use explicit n…
devin-ai-integration[bot] May 27, 2026
239ceb8
fix(wallets): update stale comment references to #resolvedServerSigner
devin-ai-integration[bot] May 27, 2026
56a3723
refactor(wallets): consolidate server signer derivation resolution in…
devin-ai-integration[bot] May 28, 2026
2aefc11
fix(wallets): handle legacy delegated server signers in resolveServer…
devin-ai-integration[bot] May 28, 2026
018bd5f
fix(wallets): preserve API-sourced delegated signer addresses for leg…
devin-ai-integration[bot] May 28, 2026
7941682
refactor(wallets): extract DerivedServerSigner type, generalize API a…
devin-ai-integration[bot] May 28, 2026
c49a201
refactor(wallets): rename API address fields to be explicitly server-…
devin-ai-integration[bot] May 28, 2026
a778035
perf(wallets): fetch signer list once in resolveServerSigner
devin-ai-integration[bot] May 28, 2026
7fa85f0
refactor(wallets): restore deriveServerSignerCandidates name with imp…
devin-ai-integration[bot] May 29, 2026
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
11 changes: 11 additions & 0 deletions .changeset/evm-server-signer-normalization.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
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") {
Comment thread
guilleasz-crossmint marked this conversation as resolved.
const legacyBytes = deriveKeyBytes(signer.secret, projectId, environment, chainStr);
const legacyAddress = deriveServerSignerAddress(legacyBytes, chain);
return { primary, legacy: { derivedKeyBytes: legacyBytes, derivedAddress: legacyAddress } };
}

return { primary, legacy: null };
}
7 changes: 6 additions & 1 deletion packages/wallets/src/signers/server/helpers/index.ts
Original file line number Diff line number Diff line change
@@ -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";
7 changes: 6 additions & 1 deletion packages/wallets/src/signers/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
67 changes: 66 additions & 1 deletion packages/wallets/src/signers/server/server-signers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 };

Expand All @@ -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();
}
});
});
6 changes: 6 additions & 0 deletions packages/wallets/src/utils/server-key-derivation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
3 changes: 2 additions & 1 deletion packages/wallets/src/utils/server-key-derivation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 4 additions & 2 deletions packages/wallets/src/wallets/evm.ts
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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<EVMChain> {
Expand All @@ -28,6 +28,8 @@ export class EVMWallet extends Wallet<EVMChain> {
options: Wallet.getOptions(wallet),
alias: wallet.alias,
recovery: Wallet.getRecovery(wallet),
apiServerRecoveryAddress: Wallet.getApiServerRecoveryAddress(wallet),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets go with apiRecoveryServerSignerAddress for clarity. api and server can clash easily and confuse

apiServerDelegatedAddresses: Wallet.getApiServerDelegatedAddresses(wallet),
signer: wallet.signer,
signers: Wallet.getInitialSigners(wallet),
},
Expand Down Expand Up @@ -222,7 +224,7 @@ export class EVMWallet extends Wallet<EVMChain> {
} 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: {
Expand Down
5 changes: 3 additions & 2 deletions packages/wallets/src/wallets/solana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SolanaChain> {
Expand All @@ -25,6 +24,8 @@ export class SolanaWallet extends Wallet<SolanaChain> {
options: Wallet.getOptions(wallet),
alias: wallet.alias,
recovery: Wallet.getRecovery(wallet),
apiServerRecoveryAddress: Wallet.getApiServerRecoveryAddress(wallet),
apiServerDelegatedAddresses: Wallet.getApiServerDelegatedAddresses(wallet),
signer: wallet.signer,
signers: Wallet.getInitialSigners(wallet),
},
Expand Down Expand Up @@ -103,7 +104,7 @@ export class SolanaWallet extends Wallet<SolanaChain> {
} 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;
Expand Down
5 changes: 3 additions & 2 deletions packages/wallets/src/wallets/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -28,6 +27,8 @@ export class StellarWallet extends Wallet<StellarChain> {
options: Wallet.getOptions(wallet),
alias: wallet.alias,
recovery: Wallet.getRecovery(wallet),
apiServerRecoveryAddress: Wallet.getApiServerRecoveryAddress(wallet),
apiServerDelegatedAddresses: Wallet.getApiServerDelegatedAddresses(wallet),
signer: wallet.signer,
signers: Wallet.getInitialSigners(wallet),
},
Expand Down Expand Up @@ -274,7 +275,7 @@ export class StellarWallet extends Wallet<StellarChain> {
if (typeof signerOverride === "string") {
return signerOverride;
}
return `server:${deriveServerSignerDetails(signerOverride, this.chain, this.apiClient.projectId, this.apiClient.environment).derivedAddress}`;
return this.resolveServerSignerApiLocator(signerOverride);
}
}

Expand Down
27 changes: 23 additions & 4 deletions packages/wallets/src/wallets/wallet-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 &&
Expand All @@ -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 apiServerRecoveryAddress =
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 apiServerDelegatedAddresses = (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,
Expand All @@ -212,6 +226,8 @@ export class WalletFactory {
options: args.options,
alias: args.alias,
recovery,
apiServerRecoveryAddress,
apiServerDelegatedAddresses,
signers: (signers ?? []) as SignerConfigForChain<C>[],
},
this.apiClient
Expand Down Expand Up @@ -342,13 +358,16 @@ export class WalletFactory {
return true;
}
if (inputSigner.type === "server") {
const { derivedAddress } = deriveServerSignerDetails(
const { primary, legacy } = deriveServerSignerCandidates(
Comment thread
guilleasz-crossmint marked this conversation as resolved.
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);
});
Expand Down
Loading
Loading