-
Notifications
You must be signed in to change notification settings - Fork 35
tests: add EVM + Solana latency benchmark specs #1913
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
devin-ai-integration
wants to merge
15
commits into
main
Choose a base branch
from
devin/1781045813-evm-latency-benchmark
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
5918835
tests: add EVM latency benchmark spec with device signer hash fix
daniil-dovgal b174b5a
fix: biome formatting and lint (non-null assertion)
daniil-dovgal b6f1c9a
feat: add Solana latency benchmark alongside EVM benchmark
daniil-dovgal e9f8d9a
fix: biome formatting for solana benchmark imports
daniil-dovgal 83e6f5d
fix: remove unused lastValidBlockHeight destructuring
daniil-dovgal cf687d9
fix: use Solana Keypair.sign() for recovery signer (not EVM string si…
daniil-dovgal f118216
fix: biome formatting for solana benchmark imports
daniil-dovgal fa00550
feat: add stellar-latency-benchmark.spec.ts for TX lifecycle measurement
daniil-dovgal 3fee90e
fix: remove unused ZERO_VALUE_TRANSFER_PARAMS constant
daniil-dovgal a183671
fix: use XLM SAC testnet contract address instead of recipient wallet…
daniil-dovgal c7bf9d7
fix: properly sign payload in stellar recovery signer
daniil-dovgal 9c7e6da
fix: remove redundant Buffer.from() wrap on keypair.sign() result
daniil-dovgal 32048f6
feat: add [STELLAR LATENCY] logs to SDK sendTransaction/approve/polli…
daniil-dovgal 8654d6e
fix: resolve TS2339 by narrowing GetTransactionResponse union in debu…
daniil-dovgal ec0caad
feat: add generalized latency logs and polling overhead measurement
daniil-dovgal File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
159 changes: 159 additions & 0 deletions
159
apps/wallets/quickstart-devkit/tests/sdk/specs/latency/evm-latency-benchmark.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }); | ||
|
|
||
| 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); | ||
| }); | ||
| }); | ||
| } | ||
| }); | ||
183 changes: 183 additions & 0 deletions
183
apps/wallets/quickstart-devkit/tests/sdk/specs/latency/solana-latency-benchmark.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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()}). Withretries: 1configured in thesdkplaywright project, a single flaky CI run creates up to 8 orphaned wallets, and there is noafterAll/afterEachcleanup. 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 totest.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
There was a problem hiding this comment.
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
@latencyand designed for manual/on-demand runs against preview, not scheduled CI. TheDate.now()suffix ensures test isolation across runs. Preview dev accounts are ephemeral by nature — no cleanup needed for a testnet environment.