Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ export class MockDeviceSignerKeyStorage extends DeviceSignerKeyStorage {
? (message as `0x${string}`)
: (`0x${Buffer.from(message, "base64").toString("hex")}` as `0x${string}`);

// Sign raw digest without additional hashing (message is a pre-computed hash)
const { r, s } = P256.sign({ payload: messageHex, hash: false, privateKey });
// The server sends authenticatorData || sha256(clientDataJSON) (69 bytes).
// P256 sign with hash: true so the payload is SHA-256'd before signing,
// matching the on-chain WebAuthn signature verification.
const { r, s } = P256.sign({ payload: messageHex, hash: true, privateKey });
return {
r: `0x${r.toString(16).padStart(64, "0")}`,
s: `0x${s.toString(16).padStart(64, "0")}`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { test, expect } from "@playwright/test";
import { CrossmintWallets, createCrossmint, EVMWallet } from "@crossmint/wallets-sdk";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
import {
AUTH_CONFIG,
TEST_RECIPIENT_WALLET_ADDRESSES,
validateAPITestConfig,
} from "../../../shared/constants/globalConstants";
import { MockDeviceSignerKeyStorage } from "../../helpers/mock-device-storage";

const API_KEY = AUTH_CONFIG.crossmintApiKey;
const BASE_URL = process.env.CROSSMINT_BASE_URL || "https://preview.crossmint.com";

function makeSdk() {
return CrossmintWallets.from(
createCrossmint({
apiKey: API_KEY,
overrideBaseUrl: BASE_URL,
})
);
}

function makeEvmRecovery() {
const admin = privateKeyToAccount(generatePrivateKey());
return {
type: "external-wallet" as const,
address: admin.address,
onSign: async (payload: string) => admin.signMessage({ message: { raw: payload as `0x${string}` } }),
};
}

type PhaseTimings = {
phase: string;
durationMs: number;
};

function logTimingTable(testName: string, timings: PhaseTimings[]) {
const totalMs = timings.reduce((sum, t) => sum + t.durationMs, 0);
console.log(`\n${"=".repeat(70)}`);
console.log(`LATENCY BENCHMARK: ${testName}`);
console.log(`${"=".repeat(70)}`);
console.log(`${"Phase".padEnd(50)} ${"Duration".padStart(10)}`);
console.log(`${"-".repeat(50)} ${"-".repeat(10)}`);
for (const t of timings) {
console.log(`${t.phase.padEnd(50)} ${`${t.durationMs.toFixed(0)}ms`.padStart(10)}`);
}
console.log(`${"-".repeat(50)} ${"-".repeat(10)}`);
console.log(`${"TOTAL".padEnd(50)} ${`${totalMs.toFixed(0)}ms`.padStart(10)}`);
console.log(`${"=".repeat(70)}\n`);
}

// Zero-value call to the recipient address — gas is sponsored via paymaster,
// so the wallet doesn't need any token balance.
const ZERO_VALUE_TX_PARAMS = {
to: TEST_RECIPIENT_WALLET_ADDRESSES.evm,
value: BigInt(0),
data: "0x" as `0x${string}`,
};

test.describe("EVM Latency Benchmark — Device Signer", { tag: "@latency" }, () => {
test.beforeAll(() => {
validateAPITestConfig();
console.log(`\nTarget environment: ${BASE_URL}`);
});

for (const { chain, bundlerPath } of [
{ chain: "base-sepolia" as const, bundlerPath: "UltraRelay" },
{ chain: "polygon-amoy" as const, bundlerPath: "Pimlico (Traditional)" },
]) {
test.describe(`${chain} (${bundlerPath})`, () => {
let sdk: CrossmintWallets;
let storage: MockDeviceSignerKeyStorage;

test.beforeEach(() => {
sdk = makeSdk();
storage = new MockDeviceSignerKeyStorage(API_KEY);
});

test(`end-to-end: sendTransaction() — full SDK latency`, async () => {
const timings: PhaseTimings[] = [];

const t0 = performance.now();
const deviceDesc = await sdk.createDeviceSigner(storage);
timings.push({ phase: "createDeviceSigner()", durationMs: performance.now() - t0 });
Comment on lines +77 to +84

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Fresh wallet per run accumulates server-side state

Each test invocation creates a brand-new user account on preview.crossmint.com (userId:latency-e2e-${chain}-${Date.now()} / userId:latency-phased-${chain}-${Date.now()}). With retries: 1 configured in the sdk playwright project, a single flaky CI run creates up to 8 orphaned wallets, and there is no afterAll / afterEach cleanup. If these tests run in CI regularly, preview will accumulate unbounded throw-away user accounts. Consider using a stable, deterministic owner ID (e.g. userId:latency-e2e-${chain}) and moving wallet creation to test.beforeAll — that way the same wallet is reused across runs, and key rotation is handled by the test's device signer storage.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/wallets/quickstart-devkit/tests/sdk/specs/latency/evm-latency-benchmark.spec.ts
Line: 77-84

Comment:
**Fresh wallet per run accumulates server-side state**

Each test invocation creates a brand-new user account on `preview.crossmint.com` (`userId:latency-e2e-${chain}-${Date.now()}` / `userId:latency-phased-${chain}-${Date.now()}`). With `retries: 1` configured in the `sdk` playwright project, a single flaky CI run creates up to 8 orphaned wallets, and there is no `afterAll` / `afterEach` cleanup. If these tests run in CI regularly, preview will accumulate unbounded throw-away user accounts. Consider using a stable, deterministic owner ID (e.g. `userId:latency-e2e-${chain}`) and moving wallet creation to `test.beforeAll` — that way the same wallet is reused across runs, and key rotation is handled by the test's device signer storage.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Appreciate the cold-blooded vigilance 🦎, but this one's a miss.

These benchmarks intentionally create fresh wallets each run — wallet creation latency is one of the things being measured. Reusing a stable owner would turn createWallet() into a cache hit, making the timing data useless for benchmarking.

Also these tests are tagged @latency and designed for manual/on-demand runs against preview, not scheduled CI. The Date.now() suffix ensures test isolation across runs. Preview dev accounts are ephemeral by nature — no cleanup needed for a testnet environment.


const t1 = performance.now();
const wallet = await sdk.createWallet({
chain,
recovery: makeEvmRecovery(),
signers: [deviceDesc],
options: { deviceSignerKeyStorage: storage },
owner: `userId:latency-e2e-${chain}-${Date.now()}`,
});
timings.push({ phase: "createWallet()", durationMs: performance.now() - t1 });

expect(wallet.address).toMatch(/^0x[a-fA-F0-9]{40}$/);

const evmWallet = EVMWallet.from(wallet);

// Full flow: create tx → device sign → approve → broadcast → poll
const t2 = performance.now();
const tx = await evmWallet.sendTransaction(ZERO_VALUE_TX_PARAMS);
const sendMs = performance.now() - t2;
timings.push({
phase: "sendTransaction() [create+sign+approve+poll]",
durationMs: sendMs,
});

expect(tx.hash).toMatch(/^0x[a-fA-F0-9]+$/);
expect(tx.transactionId).toBeTruthy();

logTimingTable(`${chain} — sendTransaction() end-to-end`, timings);
});

test(`phased: prepareOnly + approve — create vs approve+confirm`, async () => {
const timings: PhaseTimings[] = [];

const deviceDesc = await sdk.createDeviceSigner(storage);
const wallet = await sdk.createWallet({
chain,
recovery: makeEvmRecovery(),
signers: [deviceDesc],
options: { deviceSignerKeyStorage: storage },
owner: `userId:latency-phased-${chain}-${Date.now()}`,
});

expect(wallet.address).toMatch(/^0x[a-fA-F0-9]{40}$/);

const evmWallet = EVMWallet.from(wallet);

// Phase 1: Create transaction only (server-side assembleTransaction)
const t1 = performance.now();
const preparedTx = await evmWallet.sendTransaction({
...ZERO_VALUE_TX_PARAMS,
options: { prepareOnly: true },
});
timings.push({
phase: "Phase 1: sendTransaction(prepareOnly) [create-tx]",
durationMs: performance.now() - t1,
});

const txId = preparedTx.transactionId;
expect(txId).toBeTruthy();

// Phase 2+3+4: Approve (device sign + submit) → Execute → Confirm
const t2 = performance.now();
const result = await wallet.approve({ transactionId: txId as string });
timings.push({
phase: "Phase 2+3+4: approve() [sign+submit+confirm]",
durationMs: performance.now() - t2,
});

expect(result.hash).toMatch(/^0x[a-fA-F0-9]+$/);

logTimingTable(`${chain} — phased breakdown`, timings);
});
});
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { test, expect } from "@playwright/test";
import { CrossmintWallets, createCrossmint, SolanaWallet } from "@crossmint/wallets-sdk";
import {
Connection,
Keypair,
PublicKey,
SystemProgram,
TransactionMessage,
VersionedTransaction,
} from "@solana/web3.js";
import {
AUTH_CONFIG,
TEST_RECIPIENT_WALLET_ADDRESSES,
validateAPITestConfig,
} from "../../../shared/constants/globalConstants";

const API_KEY = AUTH_CONFIG.crossmintApiKey;
const BASE_URL = process.env.CROSSMINT_BASE_URL || "https://preview.crossmint.com";
const SOLANA_RPC_URL = process.env.SOLANA_RPC_URL || "https://api.devnet.solana.com";

function makeSdk() {
return CrossmintWallets.from(
createCrossmint({
apiKey: API_KEY,
overrideBaseUrl: BASE_URL,
})
);
}

function makeSolanaRecovery() {
const keypair = Keypair.generate();
return {
type: "external-wallet" as const,
address: keypair.publicKey.toBase58(),
onSign: async (transaction: VersionedTransaction) => {
transaction.sign([keypair]);
return transaction;
},
};
}

type PhaseTimings = {
phase: string;
durationMs: number;
};

function logTimingTable(testName: string, timings: PhaseTimings[]) {
const totalMs = timings.reduce((sum, t) => sum + t.durationMs, 0);
console.log(`\n${"=".repeat(70)}`);
console.log(`LATENCY BENCHMARK: ${testName}`);
console.log(`${"=".repeat(70)}`);
console.log(`${"Phase".padEnd(50)} ${"Duration".padStart(10)}`);
console.log(`${"-".repeat(50)} ${"-".repeat(10)}`);
for (const t of timings) {
console.log(`${t.phase.padEnd(50)} ${`${t.durationMs.toFixed(0)}ms`.padStart(10)}`);
}
console.log(`${"-".repeat(50)} ${"-".repeat(10)}`);
console.log(`${"TOTAL".padEnd(50)} ${`${totalMs.toFixed(0)}ms`.padStart(10)}`);
console.log(`${"=".repeat(70)}\n`);
}

async function buildZeroTransferTransaction(
fromAddress: string,
toAddress: string,
connection: Connection
): Promise<VersionedTransaction> {
const from = new PublicKey(fromAddress);
const to = new PublicKey(toAddress);

const { blockhash } = await connection.getLatestBlockhash("confirmed");

const instruction = SystemProgram.transfer({
fromPubkey: from,
toPubkey: to,
lamports: 0,
});

const messageV0 = new TransactionMessage({
payerKey: from,
recentBlockhash: blockhash,
instructions: [instruction],
}).compileToV0Message();

return new VersionedTransaction(messageV0);
}

test.describe("Solana Latency Benchmark", { tag: "@latency" }, () => {
let sdk: CrossmintWallets;
let connection: Connection;

test.beforeAll(() => {
validateAPITestConfig();
console.log(`\nTarget environment: ${BASE_URL}`);
console.log(`Solana RPC: ${SOLANA_RPC_URL}`);
});

test.beforeEach(() => {
sdk = makeSdk();
connection = new Connection(SOLANA_RPC_URL, "confirmed");
});

test(`end-to-end: sendTransaction() — full SDK latency`, async () => {
const timings: PhaseTimings[] = [];

const t1 = performance.now();
const wallet = await sdk.createWallet({
chain: "solana",
recovery: makeSolanaRecovery(),
owner: `userId:latency-solana-e2e-${Date.now()}`,
});
timings.push({ phase: "createWallet()", durationMs: performance.now() - t1 });

expect(wallet.address).toBeTruthy();

const solanaWallet = SolanaWallet.from(wallet);

const t2 = performance.now();
const transaction = await buildZeroTransferTransaction(
wallet.address,
TEST_RECIPIENT_WALLET_ADDRESSES.solana,
connection
);
timings.push({ phase: "buildTransaction() [client-side]", durationMs: performance.now() - t2 });

const t3 = performance.now();
const tx = await solanaWallet.sendTransaction({ transaction });
timings.push({
phase: "sendTransaction() [create+sign+approve+confirm]",
durationMs: performance.now() - t3,
});

expect(tx.hash).toBeTruthy();
expect(tx.transactionId).toBeTruthy();

logTimingTable("Solana — sendTransaction() end-to-end", timings);
});

test(`phased: prepareOnly + approve — create vs approve+confirm`, async () => {
const timings: PhaseTimings[] = [];

const wallet = await sdk.createWallet({
chain: "solana",
recovery: makeSolanaRecovery(),
owner: `userId:latency-solana-phased-${Date.now()}`,
});

expect(wallet.address).toBeTruthy();

const solanaWallet = SolanaWallet.from(wallet);

const transaction = await buildZeroTransferTransaction(
wallet.address,
TEST_RECIPIENT_WALLET_ADDRESSES.solana,
connection
);

// Phase 1: Create transaction only (server-side assembleTransaction + prepare)
const t1 = performance.now();
const preparedTx = await solanaWallet.sendTransaction({
transaction,
options: { prepareOnly: true },
});
timings.push({
phase: "Phase 1: sendTransaction(prepareOnly) [create-tx]",
durationMs: performance.now() - t1,
});

const txId = preparedTx.transactionId;
expect(txId).toBeTruthy();

// Phase 2+3: Approve (sign) → Execute → Confirm
const t2 = performance.now();
const result = await wallet.approve({ transactionId: txId as string });
timings.push({
phase: "Phase 2+3: approve() [sign+submit+confirm]",
durationMs: performance.now() - t2,
});

expect(result.hash).toBeTruthy();

logTimingTable("Solana — phased breakdown", timings);
});
});
Loading
Loading